@openfn/ws-worker 0.2.7 → 0.2.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # ws-worker
2
2
 
3
+ ## 0.2.10
4
+
5
+ ### Patch Changes
6
+
7
+ - 30da946: Better conversion of edge conditions to only take the upstream job into account
8
+ - c1aa9b3: Leave attempt queue channel on disconnect
9
+ Allow outstanding work to finish before closing down on SIGTERM
10
+ - 60b6fba: Add a healthcheck at /livez and respond with 200 at root
11
+ - Updated dependencies [a6dd44b]
12
+ - @openfn/engine-multi@0.2.1
13
+
14
+ ## 0.2.9
15
+
16
+ ### Patch Changes
17
+
18
+ - 54d0017: Start ws-worker using node (not pnpm) by default
19
+ - 6f78b7a: Add env var for WORKER_REPO_DIR
20
+ - Updated dependencies [4a17048]
21
+ - @openfn/engine-multi@0.2.0
22
+ - @openfn/runtime@0.2.0
23
+
24
+ ## 0.2.8
25
+
26
+ ### Patch Changes
27
+
28
+ - Tweak typings
29
+
3
30
  ## 0.2.7
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -48,7 +48,7 @@ You can start a dev server (which rebuilds on save) by running:
48
48
  pnpm start:watch
49
49
  ```
50
50
 
51
- This will wrap a real runtime engine into the server (?). It will rebuild when the Worker Engine code changes (although you'll have to `pnpm build:watch` in `runtime-manager`). This will use the repo at `ENGINE_REPO_DIR` or `/tmp/openfn/repo`.
51
+ This will wrap a real runtime engine into the server. It will rebuild when the Worker Engine code changes (although you'll have to `pnpm build:watch` in `runtime-manager`). This will use the repo at `WORKER_REPO_DIR` (or a default path in /tmp)
52
52
 
53
53
  ### Disabling auto-fetch
54
54
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import Koa from 'koa';
2
3
  import { SanitizePolicies, Logger } from '@openfn/logger';
3
4
  import { Channel as Channel$1 } from 'phoenix';
5
+ import { Server } from 'http';
4
6
  import { RuntimeEngine } from '@openfn/engine-multi';
5
7
 
6
8
  type ExitReasonStrings =
@@ -17,6 +19,40 @@ type ExitReason = {
17
19
  error_type: string | null;
18
20
  };
19
21
 
22
+ type Node = {
23
+ id: string;
24
+ body?: string;
25
+ adaptor?: string;
26
+ credential_id?: any; // TODO tighten this up, string or object
27
+ type?: 'webhook' | 'cron'; // trigger only
28
+ state?: any; // Initial state / defaults
29
+ };
30
+
31
+ interface Edge {
32
+ id: string;
33
+ source_job_id?: string;
34
+ source_trigger_id?: string;
35
+ target_job_id: string;
36
+ name?: string;
37
+ condition?: string;
38
+ error_path?: boolean;
39
+ errors?: any;
40
+ enabled?: boolean;
41
+ }
42
+
43
+ // An attempt object returned by Lightning
44
+ type Attempt = {
45
+ id: string;
46
+ dataclip_id: string;
47
+ starting_node_id: string;
48
+
49
+ triggers: Node[];
50
+ jobs: Node[];
51
+ edges: Edge[];
52
+
53
+ options?: AttemptOptions;
54
+ };
55
+
20
56
  type AttemptOptions = {
21
57
  timeout?: number;
22
58
  sanitize?: SanitizePolicies;
@@ -45,13 +81,6 @@ type ReceiveHook = {
45
81
  ) => ReceiveHook;
46
82
  };
47
83
 
48
- // export declare class Socket extends PhxSocket {
49
- // constructor(endpoint: string, options: { params: any });
50
- // onOpen(callback: () => void): void;
51
- // connect(): void;
52
- // channel(channelName: string, params: any): Channel;
53
- // }
54
-
55
84
  interface Channel extends Channel$1 {
56
85
  // on: (event: string, fn: (evt: any) => void) => void;
57
86
 
@@ -60,6 +89,68 @@ interface Channel extends Channel$1 {
60
89
  // join: () => ReceiveHook;
61
90
  }
62
91
 
92
+ declare const CLAIM = "claim";
93
+ declare type ClaimPayload = {
94
+ demand?: number;
95
+ };
96
+ declare type ClaimReply = {
97
+ attempts: Array<ClaimAttempt>;
98
+ };
99
+ declare type ClaimAttempt = {
100
+ id: string;
101
+ token: string;
102
+ };
103
+ declare const GET_ATTEMPT = "fetch:attempt";
104
+ declare type GetAttemptPayload = void;
105
+ declare type GetAttemptReply = Attempt;
106
+ declare const GET_CREDENTIAL = "fetch:credential";
107
+ declare type GetCredentialPayload = {
108
+ id: string;
109
+ };
110
+ declare type GetCredentialReply = {};
111
+ declare const GET_DATACLIP = "fetch:dataclip";
112
+ declare type GetDataclipPayload = {
113
+ id: string;
114
+ };
115
+ declare type GetDataClipReply = Uint8Array;
116
+ declare const ATTEMPT_START = "attempt:start";
117
+ declare type AttemptStartPayload = void;
118
+ declare type AttemptStartReply = {};
119
+ declare const ATTEMPT_COMPLETE = "attempt:complete";
120
+ declare type AttemptCompletePayload = ExitReason & {
121
+ final_dataclip_id?: string;
122
+ };
123
+ declare type AttemptCompleteReply = undefined;
124
+ declare const ATTEMPT_LOG = "attempt:log";
125
+ declare type AttemptLogPayload = {
126
+ message: Array<string | object>;
127
+ timestamp: string;
128
+ attempt_id: string;
129
+ level?: string;
130
+ source?: string;
131
+ job_id?: string;
132
+ run_id?: string;
133
+ };
134
+ declare type AttemptLogReply = void;
135
+ declare const RUN_START = "run:start";
136
+ declare type RunStartPayload = {
137
+ job_id: string;
138
+ run_id: string;
139
+ attempt_id?: string;
140
+ input_dataclip_id?: string;
141
+ };
142
+ declare type RunStartReply = void;
143
+ declare const RUN_COMPLETE = "run:complete";
144
+ declare type RunCompletePayload = ExitReason & {
145
+ attempt_id?: string;
146
+ job_id: string;
147
+ run_id: string;
148
+ output_dataclip?: string;
149
+ output_dataclip_id?: string;
150
+ };
151
+ declare type RunCompleteReply = void;
152
+ declare const INTERNAL_ATTEMPT_COMPLETE = "server:attempt-complete";
153
+
63
154
  declare type Context = {
64
155
  channel: Channel;
65
156
  state: AttemptState;
@@ -67,11 +158,6 @@ declare type Context = {
67
158
  onFinish: (result: any) => void;
68
159
  };
69
160
 
70
- declare type CLAIM_ATTEMPT = {
71
- id: string;
72
- token: string;
73
- };
74
-
75
161
  declare type ServerOptions = {
76
162
  maxWorkflows?: number;
77
163
  port?: number;
@@ -86,13 +172,17 @@ declare type ServerOptions = {
86
172
  };
87
173
  interface ServerApp extends Koa {
88
174
  id: string;
89
- socket: any;
90
- channel: Channel;
175
+ socket?: any;
176
+ queueChannel?: Channel;
91
177
  workflows: Record<string, true | Context>;
92
- execute: ({ id, token }: CLAIM_ATTEMPT) => Promise<void>;
178
+ destroyed: boolean;
179
+ events: EventEmitter;
180
+ server: Server;
181
+ engine: RuntimeEngine;
182
+ execute: ({ id, token }: ClaimAttempt) => Promise<void>;
93
183
  destroy: () => void;
94
184
  killWorkloop?: () => void;
95
185
  }
96
186
  declare function createServer(engine: RuntimeEngine, options?: ServerOptions): ServerApp;
97
187
 
98
- export { createServer as default };
188
+ export { ATTEMPT_COMPLETE, ATTEMPT_LOG, ATTEMPT_START, AttemptCompletePayload, AttemptCompleteReply, AttemptLogPayload, AttemptLogReply, AttemptStartPayload, AttemptStartReply, CLAIM, ClaimAttempt, ClaimPayload, ClaimReply, GET_ATTEMPT, GET_CREDENTIAL, GET_DATACLIP, GetAttemptPayload, GetAttemptReply, GetCredentialPayload, GetCredentialReply, GetDataClipReply, GetDataclipPayload, INTERNAL_ATTEMPT_COMPLETE, RUN_COMPLETE, RUN_START, RunCompletePayload, RunCompleteReply, RunStartPayload, RunStartReply, createServer as default };
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/server.ts
2
+ import { EventEmitter as EventEmitter2 } from "node:events";
2
3
  import Koa from "koa";
3
4
  import bodyParser from "koa-bodyparser";
4
5
  import koaLogger from "koa-logger";
@@ -6,6 +7,63 @@ import Router from "@koa/router";
6
7
  import { humanId } from "human-id";
7
8
  import { createMockLogger as createMockLogger2 } from "@openfn/logger";
8
9
 
10
+ // src/events.ts
11
+ var CLAIM = "claim";
12
+ var GET_ATTEMPT = "fetch:attempt";
13
+ var GET_CREDENTIAL = "fetch:credential";
14
+ var GET_DATACLIP = "fetch:dataclip";
15
+ var ATTEMPT_START = "attempt:start";
16
+ var ATTEMPT_COMPLETE = "attempt:complete";
17
+ var ATTEMPT_LOG = "attempt:log";
18
+ var RUN_START = "run:start";
19
+ var RUN_COMPLETE = "run:complete";
20
+ var INTERNAL_ATTEMPT_COMPLETE = "server:attempt-complete";
21
+
22
+ // src/api/destroy.ts
23
+ var destroy = async (app, logger) => {
24
+ logger.info("Closing server...");
25
+ await Promise.all([
26
+ new Promise((resolve) => {
27
+ app.destroyed = true;
28
+ app.killWorkloop?.();
29
+ app.queueChannel?.leave();
30
+ app.server.close(async () => {
31
+ resolve();
32
+ });
33
+ }),
34
+ new Promise(async (resolve) => {
35
+ await waitForAttempts(app, logger);
36
+ await app.engine.destroy();
37
+ app.socket?.disconnect();
38
+ resolve();
39
+ })
40
+ ]);
41
+ logger.success("Server closed");
42
+ };
43
+ var waitForAttempts = (app, logger) => new Promise((resolve) => {
44
+ const log = () => {
45
+ logger.debug(
46
+ `Waiting for ${Object.keys(app.workflows).length} attempts to complete...`
47
+ );
48
+ };
49
+ const onAttemptComplete = () => {
50
+ if (Object.keys(app.workflows).length === 0) {
51
+ logger.debug("All attempts completed!");
52
+ app.events.off(INTERNAL_ATTEMPT_COMPLETE, onAttemptComplete);
53
+ resolve();
54
+ } else {
55
+ log();
56
+ }
57
+ };
58
+ if (Object.keys(app.workflows).length) {
59
+ log();
60
+ app.events.on(INTERNAL_ATTEMPT_COMPLETE, onAttemptComplete);
61
+ } else {
62
+ resolve();
63
+ }
64
+ });
65
+ var destroy_default = destroy;
66
+
9
67
  // src/util/try-with-backoff.ts
10
68
  var BACKOFF_MULTIPLIER = 1.15;
11
69
  var tryWithBackoff = (fn, opts = {}) => {
@@ -49,19 +107,6 @@ var try_with_backoff_default = tryWithBackoff;
49
107
 
50
108
  // src/api/claim.ts
51
109
  import { createMockLogger } from "@openfn/logger";
52
-
53
- // src/events.ts
54
- var CLAIM = "claim";
55
- var GET_ATTEMPT = "fetch:attempt";
56
- var GET_CREDENTIAL = "fetch:credential";
57
- var GET_DATACLIP = "fetch:dataclip";
58
- var ATTEMPT_START = "attempt:start";
59
- var ATTEMPT_COMPLETE = "attempt:complete";
60
- var ATTEMPT_LOG = "attempt:log";
61
- var RUN_START = "run:start";
62
- var RUN_COMPLETE = "run:complete";
63
-
64
- // src/api/claim.ts
65
110
  var mockLogger = createMockLogger();
66
111
  var claim = (app, logger = mockLogger, maxWorkers = 5) => {
67
112
  return new Promise((resolve, reject) => {
@@ -69,8 +114,11 @@ var claim = (app, logger = mockLogger, maxWorkers = 5) => {
69
114
  if (activeWorkers >= maxWorkers) {
70
115
  return reject(new Error("Server at capacity"));
71
116
  }
117
+ if (!app.queueChannel) {
118
+ return reject(new Error("No websocket available"));
119
+ }
72
120
  logger.debug("requesting attempt...");
73
- app.channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
121
+ app.queueChannel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
74
122
  logger.debug(`pulled ${attempts.length} attempts`);
75
123
  if (!attempts?.length) {
76
124
  return reject(new Error("No attempts returned"));
@@ -112,6 +160,7 @@ var startWorkloop = (app, logger, minBackoff, maxBackoff, maxWorkers) => {
112
160
  logger.debug("cancelling workloop");
113
161
  cancelled = true;
114
162
  promise.cancel();
163
+ app.queueChannel?.leave();
115
164
  };
116
165
  };
117
166
  var workloop_default = startWorkloop;
@@ -122,13 +171,15 @@ import crypto2 from "node:crypto";
122
171
  // src/util/convert-attempt.ts
123
172
  import crypto from "node:crypto";
124
173
  var conditions = {
125
- on_job_success: "!state.errors",
126
- on_job_failure: "state.errors",
127
- always: null
174
+ on_job_success: (upstreamId) => `Boolean(!state.errors?.["${upstreamId}"] ?? true)`,
175
+ on_job_failure: (upstreamId) => `Boolean(state.errors && state.errors["${upstreamId}"])`,
176
+ always: (_upstreamId) => null
128
177
  };
129
- var mapEdgeCondition = (condition) => {
178
+ var mapEdgeCondition = (edge) => {
179
+ const { condition } = edge;
130
180
  if (condition && condition in conditions) {
131
- return conditions[condition];
181
+ const upstream = edge.source_job_id || edge.source_trigger_id;
182
+ return conditions[condition](upstream);
132
183
  }
133
184
  return condition;
134
185
  };
@@ -177,7 +228,7 @@ var convert_attempt_default = (attempt) => {
177
228
  }
178
229
  const next = edges.filter((e) => e.source_job_id === id).reduce((obj, edge) => {
179
230
  const newEdge = {};
180
- const condition = mapEdgeCondition(edge.condition);
231
+ const condition = mapEdgeCondition(edge);
181
232
  if (condition) {
182
233
  newEdge.condition = condition;
183
234
  }
@@ -446,6 +497,11 @@ async function loadCredential(channel, credentialId) {
446
497
  return get_with_reply_default(channel, GET_CREDENTIAL, { id: credentialId });
447
498
  }
448
499
 
500
+ // src/middleware/healthcheck.ts
501
+ var healthcheck_default = (ctx) => {
502
+ ctx.status = 200;
503
+ };
504
+
449
505
  // src/channels/attempt.ts
450
506
  var joinAttemptChannel = (socket, token, attemptId, logger) => {
451
507
  return new Promise((resolve, reject) => {
@@ -481,14 +537,14 @@ import { WebSocket } from "ws";
481
537
  // src/util/worker-token.ts
482
538
  import * as jose from "jose";
483
539
  var alg = "HS256";
484
- var generateWorkerToken = async (secret, workerId) => {
540
+ var generateWorkerToken = async (secret, workerId, logger) => {
485
541
  if (!secret) {
486
- console.warn();
487
- console.warn("WARNING: Worker Secret not provided!");
488
- console.warn(
542
+ logger.warn();
543
+ logger.warn("WARNING: Worker Secret not provided!");
544
+ logger.warn(
489
545
  "This worker will attempt to connect to Lightning with default secret"
490
546
  );
491
- console.warn();
547
+ logger.warn();
492
548
  }
493
549
  const encodedSecret = new TextEncoder().encode(secret || "<secret>");
494
550
  const claims = {
@@ -502,7 +558,7 @@ var worker_token_default = generateWorkerToken;
502
558
  // src/channels/worker-queue.ts
503
559
  var connectToWorkerQueue = (endpoint, serverId, secret, logger, SocketConstructor = PhxSocket) => {
504
560
  const events = new EventEmitter();
505
- worker_token_default(secret, serverId).then((token) => {
561
+ worker_token_default(secret, serverId, logger).then((token) => {
506
562
  const socket = new SocketConstructor(endpoint, {
507
563
  params: { token },
508
564
  transport: WebSocket
@@ -545,7 +601,7 @@ function connect(app, logger, options = {}) {
545
601
  const onConnect = ({ socket, channel }) => {
546
602
  logger.success("Connected to Lightning at", options.lightning);
547
603
  app.socket = socket;
548
- app.channel = channel;
604
+ app.queueChannel = channel;
549
605
  if (!options.noLoop) {
550
606
  logger.info("Starting workloop");
551
607
  app.killWorkloop = workloop_default(
@@ -592,6 +648,8 @@ function createServer(engine, options = {}) {
592
648
  const app = new Koa();
593
649
  app.id = humanId({ separator: "-", capitalize: false });
594
650
  const router = new Router();
651
+ app.events = new EventEmitter2();
652
+ app.engine = engine;
595
653
  app.use(bodyParser());
596
654
  app.use(
597
655
  koaLogger((str, _args) => {
@@ -599,8 +657,12 @@ function createServer(engine, options = {}) {
599
657
  })
600
658
  );
601
659
  app.workflows = {};
602
- const server = app.listen(port);
660
+ app.destroyed = false;
661
+ app.server = app.listen(port);
603
662
  logger.success(`ws-worker ${app.id} listening on ${port}`);
663
+ process.send?.("READY");
664
+ router.get("/livez", healthcheck_default);
665
+ router.get("/", healthcheck_default);
604
666
  app.execute = async ({ id, token }) => {
605
667
  if (app.socket) {
606
668
  app.workflows[id] = true;
@@ -612,6 +674,7 @@ function createServer(engine, options = {}) {
612
674
  const onFinish = () => {
613
675
  delete app.workflows[id];
614
676
  attemptChannel.leave();
677
+ app.events.emit(INTERNAL_ATTEMPT_COMPLETE);
615
678
  };
616
679
  const context = execute(
617
680
  attemptChannel,
@@ -638,19 +701,24 @@ function createServer(engine, options = {}) {
638
701
  ctx.status = 204;
639
702
  });
640
703
  });
641
- app.destroy = async () => {
642
- logger.info("Closing server...");
643
- server.close();
644
- await engine.destroy();
645
- app.killWorkloop?.();
646
- logger.success("Server closed");
647
- };
704
+ app.destroy = () => destroy_default(app, logger);
648
705
  app.use(router.routes());
649
706
  if (options.lightning) {
650
707
  connect(app, logger, options);
651
708
  } else {
652
709
  logger.warn("No lightning URL provided");
653
710
  }
711
+ let shutdown = false;
712
+ const exit = async (signal) => {
713
+ if (!shutdown) {
714
+ shutdown = true;
715
+ logger.always(`${signal} RECEIVED: CLOSING SERVER`);
716
+ await app.destroy();
717
+ process.exit();
718
+ }
719
+ };
720
+ process.on("SIGINT", () => exit("SIGINT"));
721
+ process.on("SIGTERM", () => exit("SIGTERM"));
654
722
  app.on = (...args) => {
655
723
  return engine.on(...args);
656
724
  };
@@ -661,5 +729,15 @@ var server_default = createServer;
661
729
  // src/index.ts
662
730
  var src_default = server_default;
663
731
  export {
732
+ ATTEMPT_COMPLETE,
733
+ ATTEMPT_LOG,
734
+ ATTEMPT_START,
735
+ CLAIM,
736
+ GET_ATTEMPT,
737
+ GET_CREDENTIAL,
738
+ GET_DATACLIP,
739
+ INTERNAL_ATTEMPT_COMPLETE,
740
+ RUN_COMPLETE,
741
+ RUN_START,
664
742
  src_default as default
665
743
  };
package/dist/start.js CHANGED
@@ -4864,8 +4864,8 @@ import createLogger from "@openfn/logger";
4864
4864
  import createRTE from "@openfn/engine-multi";
4865
4865
 
4866
4866
  // src/mock/runtime-engine.ts
4867
- import crypto from "node:crypto";
4868
4867
  import { EventEmitter } from "node:events";
4868
+ import run from "@openfn/runtime";
4869
4869
 
4870
4870
  // src/mock/resolvers.ts
4871
4871
  var mockResolveCredential = (_credId) => new Promise(
@@ -4887,6 +4887,10 @@ var resolvers_default = {
4887
4887
  };
4888
4888
 
4889
4889
  // src/mock/runtime-engine.ts
4890
+ var helpers = {
4891
+ fn: (f) => (s) => f(s),
4892
+ wait: (duration) => (s) => new Promise((resolve5) => setTimeout(() => resolve5(s), duration))
4893
+ };
4890
4894
  async function createMock() {
4891
4895
  const activeWorkflows = {};
4892
4896
  const bus = new EventEmitter();
@@ -4902,73 +4906,59 @@ async function createMock() {
4902
4906
  const listen = (planId, events) => {
4903
4907
  listeners[planId] = events;
4904
4908
  };
4905
- const executeJob = async (workflowId, job, initialState = {}, resolvers = resolvers_default) => {
4906
- const { id, expression, configuration, adaptor } = job;
4907
- if (!expression && !adaptor) {
4908
- return initialState;
4909
- }
4910
- const runId = crypto.randomUUID();
4911
- const jobId = id;
4912
- if (typeof configuration === "string") {
4913
- await resolvers.credential?.(configuration);
4914
- }
4915
- const info = (...message) => {
4916
- dispatch("workflow-log", {
4917
- workflowId,
4918
- message,
4919
- level: "info",
4920
- time: (BigInt(Date.now()) * BigInt(1e3)).toString(),
4921
- name: "mck"
4922
- });
4923
- };
4924
- dispatch("job-start", { workflowId, jobId, runId });
4925
- info("Running job " + jobId);
4926
- let nextState = initialState;
4927
- if (expression?.startsWith?.("wait@")) {
4928
- const [_, delay] = expression.split("@");
4929
- nextState = initialState;
4930
- await new Promise((resolve5) => {
4931
- setTimeout(() => resolve5(), parseInt(delay));
4932
- });
4933
- } else {
4934
- try {
4935
- nextState = JSON.parse(expression);
4936
- info("Parsing expression as JSON state");
4937
- info(nextState);
4938
- } catch (e) {
4939
- nextState = initialState;
4940
- }
4941
- }
4942
- dispatch("job-complete", {
4943
- workflowId,
4944
- jobId,
4945
- state: nextState,
4946
- runId,
4947
- next: []
4948
- });
4949
- return nextState;
4950
- };
4951
- const execute2 = (xplan, options = {
4909
+ const execute2 = async (xplan, options = {
4952
4910
  resolvers: resolvers_default
4953
4911
  }) => {
4954
- if (options.throw) {
4955
- throw new Error("test error");
4956
- }
4957
- const { id, jobs, initialState } = xplan;
4958
- const workflowId = id;
4912
+ const { id, jobs } = xplan;
4959
4913
  activeWorkflows[id] = true;
4960
- setTimeout(() => {
4961
- dispatch("workflow-start", { workflowId });
4962
- setTimeout(async () => {
4963
- let state = initialState || {};
4964
- for (const job of jobs) {
4965
- state = await executeJob(id, job, state, options.resolvers);
4914
+ for (const job of jobs) {
4915
+ if (typeof job.configuration === "string") {
4916
+ job.configuration = await options.resolvers?.credential?.(
4917
+ job.configuration
4918
+ );
4919
+ }
4920
+ if (typeof job.expression === "string" && !job.expression.match(/export default \[/)) {
4921
+ job.expression = `export default [${job.expression}];`;
4922
+ }
4923
+ }
4924
+ const jobLogger = {
4925
+ log: (...args2) => {
4926
+ dispatch("workflow-log", {
4927
+ workflowId: id,
4928
+ level: "info",
4929
+ json: true,
4930
+ message: args2,
4931
+ time: Date.now()
4932
+ });
4933
+ }
4934
+ };
4935
+ const opts = {
4936
+ strict: false,
4937
+ jobLogger,
4938
+ ...options,
4939
+ globals: helpers,
4940
+ callbacks: {
4941
+ notify: (name, payload) => {
4942
+ dispatch(name, {
4943
+ workflowId: id,
4944
+ ...payload
4945
+ });
4966
4946
  }
4967
- setTimeout(() => {
4968
- delete activeWorkflows[id];
4969
- dispatch("workflow-complete", { workflowId });
4970
- }, 1);
4971
- }, 1);
4947
+ }
4948
+ };
4949
+ setTimeout(async () => {
4950
+ dispatch("workflow-start", { workflowId: id });
4951
+ try {
4952
+ await run(xplan, void 0, opts);
4953
+ } catch (e) {
4954
+ dispatch("workflow-error", {
4955
+ workflowId: id,
4956
+ type: e.name,
4957
+ message: e.message
4958
+ });
4959
+ }
4960
+ delete activeWorkflows[id];
4961
+ dispatch("workflow-complete", { workflowId: id });
4972
4962
  }, 1);
4973
4963
  };
4974
4964
  const getStatus = () => {
@@ -4976,17 +4966,20 @@ async function createMock() {
4976
4966
  active: Object.keys(activeWorkflows).length
4977
4967
  };
4978
4968
  };
4969
+ const destroy2 = async () => true;
4979
4970
  return {
4980
4971
  on,
4981
4972
  once,
4982
4973
  execute: execute2,
4983
4974
  getStatus,
4984
- listen
4975
+ listen,
4976
+ destroy: destroy2
4985
4977
  };
4986
4978
  }
4987
4979
  var runtime_engine_default = createMock;
4988
4980
 
4989
4981
  // src/server.ts
4982
+ import { EventEmitter as EventEmitter3 } from "node:events";
4990
4983
  import Koa from "koa";
4991
4984
  import bodyParser from "koa-bodyparser";
4992
4985
  import koaLogger from "koa-logger";
@@ -4994,6 +4987,63 @@ import Router from "@koa/router";
4994
4987
  import { humanId } from "human-id";
4995
4988
  import { createMockLogger as createMockLogger2 } from "@openfn/logger";
4996
4989
 
4990
+ // src/events.ts
4991
+ var CLAIM = "claim";
4992
+ var GET_ATTEMPT = "fetch:attempt";
4993
+ var GET_CREDENTIAL = "fetch:credential";
4994
+ var GET_DATACLIP = "fetch:dataclip";
4995
+ var ATTEMPT_START = "attempt:start";
4996
+ var ATTEMPT_COMPLETE = "attempt:complete";
4997
+ var ATTEMPT_LOG = "attempt:log";
4998
+ var RUN_START = "run:start";
4999
+ var RUN_COMPLETE = "run:complete";
5000
+ var INTERNAL_ATTEMPT_COMPLETE = "server:attempt-complete";
5001
+
5002
+ // src/api/destroy.ts
5003
+ var destroy = async (app, logger2) => {
5004
+ logger2.info("Closing server...");
5005
+ await Promise.all([
5006
+ new Promise((resolve5) => {
5007
+ app.destroyed = true;
5008
+ app.killWorkloop?.();
5009
+ app.queueChannel?.leave();
5010
+ app.server.close(async () => {
5011
+ resolve5();
5012
+ });
5013
+ }),
5014
+ new Promise(async (resolve5) => {
5015
+ await waitForAttempts(app, logger2);
5016
+ await app.engine.destroy();
5017
+ app.socket?.disconnect();
5018
+ resolve5();
5019
+ })
5020
+ ]);
5021
+ logger2.success("Server closed");
5022
+ };
5023
+ var waitForAttempts = (app, logger2) => new Promise((resolve5) => {
5024
+ const log = () => {
5025
+ logger2.debug(
5026
+ `Waiting for ${Object.keys(app.workflows).length} attempts to complete...`
5027
+ );
5028
+ };
5029
+ const onAttemptComplete = () => {
5030
+ if (Object.keys(app.workflows).length === 0) {
5031
+ logger2.debug("All attempts completed!");
5032
+ app.events.off(INTERNAL_ATTEMPT_COMPLETE, onAttemptComplete);
5033
+ resolve5();
5034
+ } else {
5035
+ log();
5036
+ }
5037
+ };
5038
+ if (Object.keys(app.workflows).length) {
5039
+ log();
5040
+ app.events.on(INTERNAL_ATTEMPT_COMPLETE, onAttemptComplete);
5041
+ } else {
5042
+ resolve5();
5043
+ }
5044
+ });
5045
+ var destroy_default = destroy;
5046
+
4997
5047
  // src/util/try-with-backoff.ts
4998
5048
  var BACKOFF_MULTIPLIER = 1.15;
4999
5049
  var tryWithBackoff = (fn, opts = {}) => {
@@ -5037,19 +5087,6 @@ var try_with_backoff_default = tryWithBackoff;
5037
5087
 
5038
5088
  // src/api/claim.ts
5039
5089
  import { createMockLogger } from "@openfn/logger";
5040
-
5041
- // src/events.ts
5042
- var CLAIM = "claim";
5043
- var GET_ATTEMPT = "fetch:attempt";
5044
- var GET_CREDENTIAL = "fetch:credential";
5045
- var GET_DATACLIP = "fetch:dataclip";
5046
- var ATTEMPT_START = "attempt:start";
5047
- var ATTEMPT_COMPLETE = "attempt:complete";
5048
- var ATTEMPT_LOG = "attempt:log";
5049
- var RUN_START = "run:start";
5050
- var RUN_COMPLETE = "run:complete";
5051
-
5052
- // src/api/claim.ts
5053
5090
  var mockLogger = createMockLogger();
5054
5091
  var claim = (app, logger2 = mockLogger, maxWorkers = 5) => {
5055
5092
  return new Promise((resolve5, reject) => {
@@ -5057,8 +5094,11 @@ var claim = (app, logger2 = mockLogger, maxWorkers = 5) => {
5057
5094
  if (activeWorkers >= maxWorkers) {
5058
5095
  return reject(new Error("Server at capacity"));
5059
5096
  }
5097
+ if (!app.queueChannel) {
5098
+ return reject(new Error("No websocket available"));
5099
+ }
5060
5100
  logger2.debug("requesting attempt...");
5061
- app.channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
5101
+ app.queueChannel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
5062
5102
  logger2.debug(`pulled ${attempts.length} attempts`);
5063
5103
  if (!attempts?.length) {
5064
5104
  return reject(new Error("No attempts returned"));
@@ -5100,23 +5140,26 @@ var startWorkloop = (app, logger2, minBackoff2, maxBackoff2, maxWorkers) => {
5100
5140
  logger2.debug("cancelling workloop");
5101
5141
  cancelled = true;
5102
5142
  promise.cancel();
5143
+ app.queueChannel?.leave();
5103
5144
  };
5104
5145
  };
5105
5146
  var workloop_default = startWorkloop;
5106
5147
 
5107
5148
  // src/api/execute.ts
5108
- import crypto3 from "node:crypto";
5149
+ import crypto2 from "node:crypto";
5109
5150
 
5110
5151
  // src/util/convert-attempt.ts
5111
- import crypto2 from "node:crypto";
5152
+ import crypto from "node:crypto";
5112
5153
  var conditions = {
5113
- on_job_success: "!state.errors",
5114
- on_job_failure: "state.errors",
5115
- always: null
5154
+ on_job_success: (upstreamId) => `Boolean(!state.errors?.["${upstreamId}"] ?? true)`,
5155
+ on_job_failure: (upstreamId) => `Boolean(state.errors && state.errors["${upstreamId}"])`,
5156
+ always: (_upstreamId) => null
5116
5157
  };
5117
- var mapEdgeCondition = (condition) => {
5158
+ var mapEdgeCondition = (edge) => {
5159
+ const { condition } = edge;
5118
5160
  if (condition && condition in conditions) {
5119
- return conditions[condition];
5161
+ const upstream = edge.source_job_id || edge.source_trigger_id;
5162
+ return conditions[condition](upstream);
5120
5163
  }
5121
5164
  return condition;
5122
5165
  };
@@ -5153,7 +5196,7 @@ var convert_attempt_default = (attempt) => {
5153
5196
  }
5154
5197
  if (attempt.jobs?.length) {
5155
5198
  attempt.jobs.forEach((job) => {
5156
- const id = job.id || crypto2.randomUUID();
5199
+ const id = job.id || crypto.randomUUID();
5157
5200
  nodes[id] = {
5158
5201
  id,
5159
5202
  configuration: job.credential_id,
@@ -5165,7 +5208,7 @@ var convert_attempt_default = (attempt) => {
5165
5208
  }
5166
5209
  const next = edges.filter((e) => e.source_job_id === id).reduce((obj, edge) => {
5167
5210
  const newEdge = {};
5168
- const condition = mapEdgeCondition(edge.condition);
5211
+ const condition = mapEdgeCondition(edge);
5169
5212
  if (condition) {
5170
5213
  newEdge.condition = condition;
5171
5214
  }
@@ -5336,7 +5379,7 @@ var sendEvent = (channel, event, payload) => new Promise((resolve5, reject) => {
5336
5379
  channel.push(event, payload).receive("error", reject).receive("timeout", () => reject(new Error("timeout"))).receive("ok", resolve5);
5337
5380
  });
5338
5381
  function onJobStart({ channel, state }, event) {
5339
- state.activeRun = crypto3.randomUUID();
5382
+ state.activeRun = crypto2.randomUUID();
5340
5383
  state.activeJob = event.jobId;
5341
5384
  const input_dataclip_id = state.inputDataclips[event.jobId];
5342
5385
  return sendEvent(channel, RUN_START, {
@@ -5354,7 +5397,7 @@ function onJobError(context, event) {
5354
5397
  }
5355
5398
  }
5356
5399
  function onJobComplete({ channel, state }, event, error) {
5357
- const dataclipId = crypto3.randomUUID();
5400
+ const dataclipId = crypto2.randomUUID();
5358
5401
  const run_id = state.activeRun;
5359
5402
  const job_id = state.activeJob;
5360
5403
  if (!state.dataclips) {
@@ -5434,6 +5477,11 @@ async function loadCredential(channel, credentialId) {
5434
5477
  return get_with_reply_default(channel, GET_CREDENTIAL, { id: credentialId });
5435
5478
  }
5436
5479
 
5480
+ // src/middleware/healthcheck.ts
5481
+ var healthcheck_default = (ctx) => {
5482
+ ctx.status = 200;
5483
+ };
5484
+
5437
5485
  // src/channels/attempt.ts
5438
5486
  var joinAttemptChannel = (socket, token, attemptId, logger2) => {
5439
5487
  return new Promise((resolve5, reject) => {
@@ -5469,14 +5517,14 @@ import { WebSocket } from "ws";
5469
5517
  // src/util/worker-token.ts
5470
5518
  import * as jose from "jose";
5471
5519
  var alg = "HS256";
5472
- var generateWorkerToken = async (secret, workerId) => {
5520
+ var generateWorkerToken = async (secret, workerId, logger2) => {
5473
5521
  if (!secret) {
5474
- console.warn();
5475
- console.warn("WARNING: Worker Secret not provided!");
5476
- console.warn(
5522
+ logger2.warn();
5523
+ logger2.warn("WARNING: Worker Secret not provided!");
5524
+ logger2.warn(
5477
5525
  "This worker will attempt to connect to Lightning with default secret"
5478
5526
  );
5479
- console.warn();
5527
+ logger2.warn();
5480
5528
  }
5481
5529
  const encodedSecret = new TextEncoder().encode(secret || "<secret>");
5482
5530
  const claims = {
@@ -5490,7 +5538,7 @@ var worker_token_default = generateWorkerToken;
5490
5538
  // src/channels/worker-queue.ts
5491
5539
  var connectToWorkerQueue = (endpoint, serverId, secret, logger2, SocketConstructor = PhxSocket) => {
5492
5540
  const events = new EventEmitter2();
5493
- worker_token_default(secret, serverId).then((token) => {
5541
+ worker_token_default(secret, serverId, logger2).then((token) => {
5494
5542
  const socket = new SocketConstructor(endpoint, {
5495
5543
  params: { token },
5496
5544
  transport: WebSocket
@@ -5533,7 +5581,7 @@ function connect(app, logger2, options = {}) {
5533
5581
  const onConnect = ({ socket, channel }) => {
5534
5582
  logger2.success("Connected to Lightning at", options.lightning);
5535
5583
  app.socket = socket;
5536
- app.channel = channel;
5584
+ app.queueChannel = channel;
5537
5585
  if (!options.noLoop) {
5538
5586
  logger2.info("Starting workloop");
5539
5587
  app.killWorkloop = workloop_default(
@@ -5580,6 +5628,8 @@ function createServer(engine, options = {}) {
5580
5628
  const app = new Koa();
5581
5629
  app.id = humanId({ separator: "-", capitalize: false });
5582
5630
  const router = new Router();
5631
+ app.events = new EventEmitter3();
5632
+ app.engine = engine;
5583
5633
  app.use(bodyParser());
5584
5634
  app.use(
5585
5635
  koaLogger((str, _args) => {
@@ -5587,8 +5637,12 @@ function createServer(engine, options = {}) {
5587
5637
  })
5588
5638
  );
5589
5639
  app.workflows = {};
5590
- const server = app.listen(port);
5640
+ app.destroyed = false;
5641
+ app.server = app.listen(port);
5591
5642
  logger2.success(`ws-worker ${app.id} listening on ${port}`);
5643
+ process.send?.("READY");
5644
+ router.get("/livez", healthcheck_default);
5645
+ router.get("/", healthcheck_default);
5592
5646
  app.execute = async ({ id, token }) => {
5593
5647
  if (app.socket) {
5594
5648
  app.workflows[id] = true;
@@ -5600,6 +5654,7 @@ function createServer(engine, options = {}) {
5600
5654
  const onFinish = () => {
5601
5655
  delete app.workflows[id];
5602
5656
  attemptChannel.leave();
5657
+ app.events.emit(INTERNAL_ATTEMPT_COMPLETE);
5603
5658
  };
5604
5659
  const context = execute(
5605
5660
  attemptChannel,
@@ -5626,19 +5681,24 @@ function createServer(engine, options = {}) {
5626
5681
  ctx.status = 204;
5627
5682
  });
5628
5683
  });
5629
- app.destroy = async () => {
5630
- logger2.info("Closing server...");
5631
- server.close();
5632
- await engine.destroy();
5633
- app.killWorkloop?.();
5634
- logger2.success("Server closed");
5635
- };
5684
+ app.destroy = () => destroy_default(app, logger2);
5636
5685
  app.use(router.routes());
5637
5686
  if (options.lightning) {
5638
5687
  connect(app, logger2, options);
5639
5688
  } else {
5640
5689
  logger2.warn("No lightning URL provided");
5641
5690
  }
5691
+ let shutdown = false;
5692
+ const exit = async (signal) => {
5693
+ if (!shutdown) {
5694
+ shutdown = true;
5695
+ logger2.always(`${signal} RECEIVED: CLOSING SERVER`);
5696
+ await app.destroy();
5697
+ process.exit();
5698
+ }
5699
+ };
5700
+ process.on("SIGINT", () => exit("SIGINT"));
5701
+ process.on("SIGTERM", () => exit("SIGTERM"));
5642
5702
  app.on = (...args2) => {
5643
5703
  return engine.on(...args2);
5644
5704
  };
@@ -5647,6 +5707,7 @@ function createServer(engine, options = {}) {
5647
5707
  var server_default = createServer;
5648
5708
 
5649
5709
  // src/start.ts
5710
+ var { WORKER_REPO_DIR, WORKER_SECRET } = process.env;
5650
5711
  var args = yargs_default(hideBin(process.argv)).command("server", "Start a ws-worker server").option("port", {
5651
5712
  alias: "p",
5652
5713
  description: "Port to run the server on",
@@ -5658,7 +5719,8 @@ var args = yargs_default(hideBin(process.argv)).command("server", "Start a ws-wo
5658
5719
  default: "ws://localhost:4000/worker"
5659
5720
  }).option("repo-dir", {
5660
5721
  alias: "d",
5661
- description: "Path to the runtime repo (where modules will be installed)"
5722
+ description: "Path to the runtime repo (where modules will be installed)",
5723
+ default: WORKER_REPO_DIR
5662
5724
  }).option("secret", {
5663
5725
  alias: "s",
5664
5726
  description: "Worker secret (comes from WORKER_SECRET by default)"
@@ -5689,7 +5751,6 @@ if (args.lightning === "mock") {
5689
5751
  args.secret = "abdefg";
5690
5752
  }
5691
5753
  } else if (!args.secret) {
5692
- const { WORKER_SECRET } = process.env;
5693
5754
  if (!WORKER_SECRET) {
5694
5755
  logger.error("WORKER_SECRET is not set");
5695
5756
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/ws-worker",
3
- "version": "0.2.7",
3
+ "version": "0.2.10",
4
4
  "description": "A Websocket Worker to connect Lightning to a Runtime Engine",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -21,9 +21,9 @@
21
21
  "koa-logger": "^3.2.1",
22
22
  "phoenix": "^1.7.7",
23
23
  "ws": "^8.14.1",
24
- "@openfn/engine-multi": "0.1.11",
25
- "@openfn/runtime": "0.1.4",
26
- "@openfn/logger": "0.0.19"
24
+ "@openfn/engine-multi": "0.2.1",
25
+ "@openfn/logger": "0.0.19",
26
+ "@openfn/runtime": "0.2.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/koa": "^2.13.5",
@@ -40,7 +40,7 @@
40
40
  "tsup": "^6.2.3",
41
41
  "typescript": "^4.6.4",
42
42
  "yargs": "^17.6.2",
43
- "@openfn/lightning-mock": "1.0.12"
43
+ "@openfn/lightning-mock": "1.1.3"
44
44
  },
45
45
  "files": [
46
46
  "dist",