@sanoma-ai/workflows 0.0.0-stage → 0.1.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.
Files changed (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
@@ -0,0 +1,111 @@
1
+ import { type RunSummary, SanomaClient } from "./client.ts";
2
+ import { resolveConfig, type SanomaConfig } from "./config.ts";
3
+ import type { Principal } from "./define.ts";
4
+ import { type LedgerRecord, memoryLedger } from "./ledger.ts";
5
+ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } from "./scenario.ts";
6
+ import { errorMessage } from "./shared.ts";
7
+ import { startWorker } from "./worker.ts";
8
+
9
+ /*
10
+ * `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
11
+ * framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
12
+ * `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
13
+ * `runScenario` in a vitest test per scenario.
14
+ */
15
+
16
+ export interface RunScenarioOptions {
17
+ /** A client of the worker that runs the scenario, whose connectors carry the fakes and whose root has the scenarios. */
18
+ client: SanomaClient;
19
+ /** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
20
+ scope: Scope;
21
+ /** Who starts the run. Defaults to `{ id: "scenarios" }`. */
22
+ startedBy?: Principal;
23
+ /** How long the run may take, passed to `drive`. */
24
+ timeoutMs?: number;
25
+ }
26
+
27
+ /**
28
+ * Runs a scenario as a sandbox run: starts its workflow with its input, decides its approvals
29
+ * as it says, and checks the run's ledger against it (`check`: Cucumber runs its `Then` steps).
30
+ */
31
+ export async function runScenario(
32
+ scenario: Scenario,
33
+ { client, scope, startedBy = { id: "scenarios" }, timeoutMs }: RunScenarioOptions,
34
+ ): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
35
+ const runId = await client.start(scenario.workflow, scenario.input, { startedBy, sandbox: scenario.name });
36
+ const run = await drive(client, runId, scenario, { timeoutMs });
37
+ const ledger = await client.ledger(runId);
38
+ return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
39
+ }
40
+
41
+ /** One step of a scenario that did not hold, or a file whose scenarios could not be read (`step` is then `did not load`). */
42
+ export interface ScenarioFailure {
43
+ /** The scenario's name, or the feature file's for one that did not load. */
44
+ scenario: string;
45
+ step: string;
46
+ message: string;
47
+ }
48
+
49
+ export interface RunScenariosOptions {
50
+ /** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
51
+ databaseUrl: string;
52
+ /** Who starts the runs. Defaults to `{ id: "scenarios" }`. */
53
+ startedBy?: Principal;
54
+ /** How long each run may take (`drive`'s; 15 seconds by default). */
55
+ timeoutMs?: number;
56
+ /** Receives a line per scenario as it ends. */
57
+ log?: (line: string) => void;
58
+ }
59
+
60
+ /**
61
+ * Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
62
+ * on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
63
+ * written), and stops when done. `passed` names the scenarios whose every check held; `failed`
64
+ * has one entry per step that did not, with `check`'s detail, and one per feature file that
65
+ * could not be read, with why. A scenario whose run cannot be driven to its end (an approval
66
+ * with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
67
+ * step. A config whose root has no `scenarios/`, or none in it, passes nothing and fails nothing.
68
+ */
69
+ export async function runScenarios(
70
+ config: SanomaConfig,
71
+ { databaseUrl, startedBy, timeoutMs, log }: RunScenariosOptions,
72
+ ): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
73
+ const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
74
+ const resolved = resolveConfig(shared);
75
+ const { scenarios, errors } = await loadScenarios(resolved);
76
+ const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
77
+ scenario: file,
78
+ step: "did not load",
79
+ message,
80
+ }));
81
+ const passed: string[] = [];
82
+ if (scenarios.length === 0) return { passed, failed };
83
+
84
+ // Quiet: the scenarios call the fakes alone, so no vendor need be configured.
85
+ const worker = await startWorker(shared, { promote: true, quiet: true });
86
+ try {
87
+ const client = await SanomaClient.connect(shared);
88
+ try {
89
+ for (const scenario of scenarios) {
90
+ const before = failed.length;
91
+ try {
92
+ const { checks } = await runScenario(scenario, { client, scope: resolved, startedBy, timeoutMs });
93
+ for (const c of checks) {
94
+ if (!c.ok) failed.push({ scenario: scenario.name, step: c.step, message: c.detail ?? "did not hold" });
95
+ }
96
+ } catch (err) {
97
+ const when = scenario.steps.find((s) => s.kind === "when")?.text ?? `${scenario.workflow} runs`;
98
+ failed.push({ scenario: scenario.name, step: when, message: errorMessage(err) });
99
+ }
100
+ const ok = failed.length === before;
101
+ if (ok) passed.push(scenario.name);
102
+ log?.(`${ok ? "passed" : "failed"} ${scenario.file} › ${scenario.name}`);
103
+ }
104
+ } finally {
105
+ await client.close();
106
+ }
107
+ } finally {
108
+ await worker.stop();
109
+ }
110
+ return { passed, failed };
111
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { CredentialStatus } from "./env.ts";
2
+ import type { ApprovalState, Approver, Principal } from "./define.ts";
3
+ import type { ResourceProblem } from "./resources.ts";
4
+ import type { OutlineNode } from "./outline.ts";
5
+
6
+ // Types only: resource types as a UI reads them. Erased from the bundle.
7
+ export type {
8
+ Declared,
9
+ Declaring,
10
+ FieldReference,
11
+ References,
12
+ Resource,
13
+ ResourceFields,
14
+ ResourceSpec,
15
+ } from "./resource.ts";
16
+ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
17
+ export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
18
+ export type { CredentialStatus } from "./env.ts";
19
+
20
+ // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
21
+ // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
22
+ // same functions, so a page answers these questions the way a run does.
23
+
24
+ /**
25
+ * What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
26
+ * (`name`); a name given may not be it.
27
+ */
28
+ export const DEFAULT_SLEEP_NAME = "Sleep";
29
+
30
+ /** The built-in drift workflow's name, which a config's own workflows may not take; a UI and the scenarios name its runs by it. */
31
+ export const DRIFT_WORKFLOW = "drift";
32
+
33
+ /**
34
+ * Every run status, in the order a run may pass through them: waiting on the queue, running,
35
+ * waiting for an approval, or ended. `waiting` is `running` with an approval pending.
36
+ */
37
+ export const RUN_STATUSES = ["queued", "running", "waiting", "finished", "failed", "cancelled"] as const;
38
+
39
+ /** Where a run is: one of `RUN_STATUSES`, so a new status reaches every list and map of them. */
40
+ export type RunStatus = (typeof RUN_STATUSES)[number];
41
+
42
+ /** The statuses of a run that has ended: it reads no more decisions. */
43
+ export const ENDED_STATUSES = ["finished", "failed", "cancelled"] as const satisfies readonly RunStatus[];
44
+
45
+ /** True when the run has finished, failed or been cancelled. */
46
+ export const isEnded = (status: RunStatus): boolean => (ENDED_STATUSES as readonly RunStatus[]).includes(status);
47
+
48
+ /**
49
+ * True when `by` may decide the approval: `by.id` is the approver, or `by.groups` holds the
50
+ * approver group. Pure: everything it reads is already recorded.
51
+ */
52
+ export function mayDecide(approval: Pick<ApprovalState, "approver">, by: Principal): boolean {
53
+ const { approver } = approval;
54
+ return typeof approver === "string" ? approver === by.id : (by.groups?.includes(approver.group) ?? false);
55
+ }
56
+
57
+ /** Who may decide, as text: the person's id, or "group <name>". */
58
+ export const approverLabel = (approver: Approver): string =>
59
+ typeof approver === "string" ? approver : `group ${approver.group}`;
60
+
61
+ /** An error's message, or the value as text when it is not an Error. */
62
+ export const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err));
63
+
64
+ /** True when an outline's op id, a computed segment shown as `*`, could be this op: `a.*.c` fits `a.b.c`. */
65
+ export function fitsOp(pattern: string, op: string): boolean {
66
+ const want = pattern.split(".");
67
+ const got = op.split(".");
68
+ return want.length === got.length && want.every((segment, i) => segment === "*" || segment === got[i]);
69
+ }
70
+
71
+ /** A node's child lists, in order: the lists a `path`'s segments index into. */
72
+ export const childLists = (node: OutlineNode): OutlineNode[][] => {
73
+ switch (node.kind) {
74
+ case "all":
75
+ return node.branches;
76
+ case "branch":
77
+ return node.cases;
78
+ case "each":
79
+ case "repeat":
80
+ return [node.body];
81
+ case "try":
82
+ return [node.body, node.handler];
83
+ default:
84
+ return [];
85
+ }
86
+ };
87
+
88
+ /** Every node of an outline, nested ones included, in order. */
89
+ export function flatten(nodes: readonly OutlineNode[]): OutlineNode[] {
90
+ return nodes.flatMap((node) => [node, ...flatten(childLists(node).flat())]);
91
+ }
92
+
93
+ /** Where a data-file problem is, `file:line:column`; undefined for one that is the config's, with no file. */
94
+ export const problemAt = (p: ResourceProblem): string | undefined =>
95
+ p.file === undefined ? undefined : `${p.file}:${p.line}:${p.column}`;
96
+
97
+ /**
98
+ * A problem as one line: `file:line:column: message`, or `file: message` without a position, or
99
+ * the message alone without a file.
100
+ */
101
+ export function problemLine(p: { file?: string; line?: number; column?: number; message: string }): string {
102
+ if (p.file === undefined) return p.message;
103
+ return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
104
+ }
105
+
106
+ /**
107
+ * Whether a host is this machine: `localhost`, `127.x.x.x` or `::1` (bare, or bracketed as a URL's
108
+ * `hostname` has it). The one rule for what the app listens on and answers to, and for where the
109
+ * bin may start Postgres.
110
+ */
111
+ export const isLoopback = (host: string) =>
112
+ ["localhost", "::1", "[::1]"].includes(host) || /^127\.\d+\.\d+\.\d+$/.test(host);
113
+
114
+ /** Whether a driver's environment variable lets it run: set, or unset and optional. */
115
+ export const credentialReady = ({ status, optional }: CredentialStatus): boolean =>
116
+ status === "set" || (status === "missing" && optional);
package/src/steps.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { LedgerRecord } from "./ledger.ts";
2
+ import type { Op } from "./op.ts";
3
+
4
+ /*
5
+ * Steps a connector (or a company) ships for its Gherkin tests, as plain data: expressions and
6
+ * functions, never Cucumber's own registration, which Sanoma does from the config. Kept free of
7
+ * Cucumber, Gherkin and faker, so a connector's package can import it on its main entry.
8
+ */
9
+
10
+ /** Any operation, whatever its input and output. */
11
+ type AnyOp<I = any, O = any> = Op<string, string, string, I, O>;
12
+
13
+ /**
14
+ * What a `Given` step sets up before the run: a call through an operation of the run's fake,
15
+ * with the fields of its input to set (the rest is made up), or a fault to inject into its next
16
+ * call. `as` names a call's output, so a later step's table of an operation's own test can use
17
+ * its fields: `$draft.id`.
18
+ */
19
+ export type Seed =
20
+ | { op: AnyOp; input: Record<string, unknown>; as?: string }
21
+ | { op: AnyOp; fault: "failNext" | "rateLimit" | "loseReply" };
22
+
23
+ /** A call through an operation: what it was given, and what it returned. */
24
+ export interface Call<I, O> {
25
+ input: I;
26
+ output: O;
27
+ }
28
+
29
+ /** A run as a `Then` step reads it. */
30
+ export interface RunView {
31
+ /** Every record of the run, in order. */
32
+ readonly records: readonly LedgerRecord[];
33
+ /** The calls the run made through the operation, in order: those that did not fail. */
34
+ calls<I, O>(op: AnyOp<I, O>): Call<I, O>[];
35
+ /** The calls the scenario's `Given` steps made through the operation, to seed the run's fake. */
36
+ seeded<I, O>(op: AnyOp<I, O>): Call<I, O>[];
37
+ /** The seeded calls, then the run's own: everything made through the operation. */
38
+ made<I, O>(op: AnyOp<I, O>): Call<I, O>[];
39
+ }
40
+
41
+ /**
42
+ * What a `Then` step expects. `check` reads the run and throws, or returns why the expectation
43
+ * does not hold; returning nothing passes. `op` and `called` say what the step is about, for the
44
+ * graph: the operation, and whether it expects a call to it (true) or rules one out (false).
45
+ */
46
+ export interface Expect {
47
+ op?: AnyOp;
48
+ called?: boolean;
49
+ check(run: RunView): string | undefined | void;
50
+ }
51
+
52
+ /** One step definition: a Cucumber expression, and what the step says, from its parameters' values. */
53
+ export type StepDefinition =
54
+ | { kind: "given"; expression: string; fn: (...values: any[]) => Seed }
55
+ | { kind: "then"; expression: string; fn: (...values: any[]) => Expect };
56
+
57
+ /** A connector's or a company's steps: `owner` (the vendor, say) heads them in a list of known steps. */
58
+ export interface Steps {
59
+ readonly owner: string;
60
+ readonly steps: readonly StepDefinition[];
61
+ }
62
+
63
+ /**
64
+ * Declares steps for Gherkin tests, as data the config's scenarios and a connector's own feature
65
+ * files can use: `given` steps say what to seed (`Seed`), `then` steps what to expect
66
+ * (`Expect`). A step's function gets its expression's values, in order, and must be pure:
67
+ * compiling a scenario calls it, and so does the app, whenever a feature file changes.
68
+ *
69
+ * ```ts
70
+ * export const ghostSteps = defineSteps("ghost", ({ given }) => {
71
+ * given("a Ghost draft titled {string}", (title: string) => ({ op: ghostOps.post.create, input: { title }, as: "draft" }));
72
+ * });
73
+ * ```
74
+ */
75
+ export function defineSteps(
76
+ owner: string,
77
+ define: (steps: {
78
+ given(expression: string, fn: (...values: any[]) => Seed): void;
79
+ then(expression: string, fn: (...values: any[]) => Expect): void;
80
+ }) => void,
81
+ ): Steps {
82
+ const steps: StepDefinition[] = [];
83
+ define({
84
+ given: (expression, fn) => void steps.push({ kind: "given", expression, fn }),
85
+ // Named for Gherkin's keyword; the object goes to `define` only, which never awaits it.
86
+ // oxlint-disable-next-line unicorn/no-thenable
87
+ then: (expression, fn) => void steps.push({ kind: "then", expression, fn }),
88
+ });
89
+ return Object.freeze({ owner, steps: Object.freeze(steps) });
90
+ }
package/src/version.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
6
+ import { upFrom } from "./paths.ts";
7
+
8
+ /** This package's version. A unit test keeps it equal to package.json. */
9
+ export const RUNTIME_VERSION = "0.1.0";
10
+
11
+ /**
12
+ * The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval
13
+ * events and messages, the driver step. Bump it whenever that sequence changes, even without a
14
+ * package release, so a run in flight on the old layout is not replayed against the new one.
15
+ * 2: calls run in program order; a decision event per approval (2026-10-07).
16
+ */
17
+ export const STEP_LAYOUT = 2;
18
+
19
+ let dbosVersion: string | undefined;
20
+
21
+ /** The installed DBOS SDK's version, read from its package.json (which its exports don't expose). */
22
+ function installedDbosVersion(): string {
23
+ dbosVersion ??= upFrom(dirname(createRequire(import.meta.url).resolve("@dbos-inc/dbos-sdk")), (dir) => {
24
+ let pkg: { name?: string; version?: string };
25
+ try {
26
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as typeof pkg;
27
+ } catch (err) {
28
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
29
+ throw err;
30
+ }
31
+ return pkg.name === "@dbos-inc/dbos-sdk" && pkg.version ? pkg.version : undefined;
32
+ });
33
+ if (!dbosVersion) throw new Error("Cannot find the package.json of @dbos-inc/dbos-sdk");
34
+ return dbosVersion;
35
+ }
36
+
37
+ /**
38
+ * The application version DBOS stamps on every run, always `<appName>@…`: DBOS version names
39
+ * are unique per system database across apps. A worker recovers and dequeues only runs of its
40
+ * own version, so the version must change whenever replaying an old run on the new code could
41
+ * go wrong.
42
+ *
43
+ * It is a hash of the app name, `RUNTIME_VERSION`, `STEP_LAYOUT`, the installed DBOS version,
44
+ * and for each workflow its name, the source text of its `run` function, the ids of the
45
+ * operations it uses and its input schema as JSON Schema: DBOS's own automatic versioning, over
46
+ * the workflow code instead of the runtime's one registration wrapper. The hash cannot see
47
+ * functions `run` calls that live elsewhere (their source is not reachable from the definition),
48
+ * op schemas, drivers or the policy. DBOS's `DBOS__APPVERSION` environment variable names a
49
+ * version instead (a git commit, say), still prefixed with the app name.
50
+ */
51
+ export function computeVersion({
52
+ appName,
53
+ workflows: definitions,
54
+ }: {
55
+ /** The resolved config's `appName`. */
56
+ appName: string;
57
+ workflows: readonly WorkflowDefinition<any, any>[];
58
+ }): string {
59
+ const named = process.env.DBOS__APPVERSION?.trim();
60
+ if (named) return `${appName}@${named}`;
61
+ const workflows = definitions
62
+ .map(
63
+ (wf) =>
64
+ [
65
+ wf.name,
66
+ wf.run.toString(),
67
+ opIdsOf(wf),
68
+ // The input schema is parsed outside steps on replay, so a change to it must change the version.
69
+ inputSchemaOf(wf),
70
+ ] as const,
71
+ )
72
+ .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
73
+ const hash = createHash("sha256")
74
+ .update(JSON.stringify([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows]))
75
+ .digest("hex");
76
+ return `${appName}@${hash}`;
77
+ }
78
+
79
+ /** The workflow's input schema as the hash sees it: its JSON Schema, or its text when zod can't convert it. */
80
+ function inputSchemaOf(wf: WorkflowDefinition<any, any>): unknown {
81
+ try {
82
+ return jsonSchemaOf(wf.input);
83
+ } catch {
84
+ return String(wf.input);
85
+ }
86
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { buildCtx, isInfrastructureError } from "./call.ts";
3
+ import { dbosStatusesOf } from "./client.ts";
4
+ import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
5
+ import { watchCredentials } from "./credentials.ts";
6
+ import { DBOS_DATABASE_OPTIONS, prepareDatabase } from "./db.ts";
7
+ import type { WorkflowDefinition } from "./define.ts";
8
+ import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
9
+ import { errorInfo, parseOrThrow } from "./errors.ts";
10
+ import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
11
+ import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
12
+ import { lineStartsOf, readSource } from "./ast.ts";
13
+ import { callsOf, outlineWithSource } from "./outline.ts";
14
+ import type { Run, RunArgs, WorkerState, WorkflowOutline } from "./run.ts";
15
+ import { errorMessage } from "./shared.ts";
16
+
17
+ export interface Worker {
18
+ /**
19
+ * The vendors whose drivers could not run when the worker started, in the config's order: a
20
+ * live call to one fails, saying why (`unconfigured` in env.ts), until its variables are set.
21
+ * The worker names them in one line at start, unless started `quiet`.
22
+ */
23
+ readonly unconfigured: readonly string[];
24
+ stop(): Promise<void>;
25
+ }
26
+
27
+ // DBOS registrations are process-wide and survive shutdown, so a name is registered once, and
28
+ // its function reads the state of the worker running when a run starts, definition and all; the
29
+ // run keeps that state.
30
+ let current: WorkerState | undefined;
31
+ const registered = new Set<string>();
32
+
33
+ export interface WorkerOptions {
34
+ /** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
35
+ logLevel?: string;
36
+ /**
37
+ * Start without naming the vendors not configured yet (`unconfigured` still does): for a worker
38
+ * whose runs use the fakes alone, as `runScenarios`'s.
39
+ */
40
+ quiet?: boolean;
41
+ /**
42
+ * Make this worker's version the app's latest, so runs queued without a version come here.
43
+ * A brand-new version becomes the latest on its own; set this only when deliberately starting
44
+ * a worker on a version seen before (a rollback). Off by default, because an old instance
45
+ * restarting during a rolling deploy would otherwise take the new workers' runs.
46
+ */
47
+ promote?: boolean;
48
+ }
49
+
50
+ /**
51
+ * Registers the workflows, connects to Postgres and recovers any runs that were interrupted.
52
+ * When a driver declares variables the environment leaves unset, it first loads their stored
53
+ * values into `process.env` and keeps them current while it runs. It starts whether or not they
54
+ * are all there: it prints one line naming the vendors that lack one (`unconfigured`), and a live
55
+ * call to such a vendor fails, saying why, as the call's own final failure, until they are set.
56
+ */
57
+ export async function startWorker(config: SanomaConfig, options: WorkerOptions = {}): Promise<Worker> {
58
+ // Check everything before touching the state a running worker reads.
59
+ const resolved = resolveConfig(config);
60
+ setLogLevel(options.logLevel);
61
+ // The config's own, or a Postgres store made for this worker, which `stop` then closes.
62
+ const ledger = ownedLedger(resolved);
63
+ const state: WorkerState = {
64
+ app: resolved.appName,
65
+ ops: resolved.ops,
66
+ drivers: resolved.drivers,
67
+ fakes: resolved.fakes,
68
+ ...(resolved.scenarios && { scenarios: resolved.scenarios }),
69
+ types: resolved.types,
70
+ steps: resolved.steps,
71
+ policy: resolved.policy,
72
+ ledger: ledger.store,
73
+ workflows: resolved.workflows,
74
+ outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
75
+ stopped: false,
76
+ };
77
+ const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
78
+ const worker: Worker = {
79
+ unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
80
+ .filter(([vendor, statuses]) => unconfigured(vendor, statuses))
81
+ .map(([vendor]) => vendor),
82
+ async stop() {
83
+ state.stopped = true;
84
+ // Each is closed whatever the one before did; the first failure is what stop throws.
85
+ let failed: { error: unknown } | undefined;
86
+ for (const close of [() => DBOS.shutdown(), () => credentials.close(), () => ledger.close()]) {
87
+ await close().catch((error: unknown) => (failed ??= { error }));
88
+ }
89
+ if (failed) throw failed.error;
90
+ },
91
+ };
92
+ // Runs recovered during launch start before it returns, so they must already find this
93
+ // state. If starting fails, a worker still running in the process gets its own back, once
94
+ // DBOS has stopped: until then a run DBOS dispatches finds this state stopped, and is refused.
95
+ const previous = current;
96
+ let launched = false;
97
+ try {
98
+ for (const name of resolved.workflows.keys()) {
99
+ if (!registered.has(name)) {
100
+ register(name);
101
+ registered.add(name);
102
+ }
103
+ }
104
+ current = state;
105
+ // Before DBOS's pool connects, so its connections start with the database's idle-transaction
106
+ // timeout (`DBOS_DATABASE_OPTIONS`); once per process and database.
107
+ await prepareDatabase(resolved.databaseUrl);
108
+ DBOS.setConfig({
109
+ name: resolved.appName,
110
+ systemDatabaseUrl: resolved.databaseUrl,
111
+ ...DBOS_DATABASE_OPTIONS,
112
+ // A logger of our own replaces DBOS's OTLP log export, so only while DBOS exports none (it
113
+ // does on DBOS Cloud); its level is then the threshold `setLogLevel` set, DBOS's own `logLevel` unread.
114
+ ...(process.env.DBOS__CLOUD === "true" ? { logLevel: options.logLevel ?? "warn" } : { logger: dbosLogger }),
115
+ applicationVersion: resolved.version,
116
+ });
117
+ await DBOS.launch();
118
+ launched = true;
119
+ // DBOS gives runs queued without a version only to the app's latest version, which is the
120
+ // newest one registered. A worker started on code seen before (a rollback) is not it.
121
+ const latest = (await DBOS.getLatestApplicationVersion()).versionName;
122
+ if (latest !== resolved.version) {
123
+ if (options.promote) await DBOS.setLatestApplicationVersion(resolved.version);
124
+ else {
125
+ warn(
126
+ `this worker runs version ${resolved.version} but the app's latest is ${latest}; ` +
127
+ "runs queued without a version go to the latest. Start with { promote: true } to take them here",
128
+ );
129
+ }
130
+ }
131
+ await DBOS.registerQueue(resolved.queueName);
132
+ await warnAboutStrandedRuns(resolved);
133
+ const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
134
+ if (summary && !options.quiet) warn(summary);
135
+ } catch (err) {
136
+ // Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
137
+ // in a worker nobody holds, and the next startWorker launches afresh.
138
+ let stopFailed: { error: unknown } | undefined;
139
+ if (launched) await worker.stop().catch((error: unknown) => (stopFailed = { error }));
140
+ else {
141
+ await credentials
142
+ .close()
143
+ .catch((e: unknown) => warn(`closing the credentials listener failed: ${errorMessage(e)}`));
144
+ }
145
+ current = previous;
146
+ if (stopFailed) {
147
+ throw new AggregateError(
148
+ [err, stopFailed.error],
149
+ `${errorMessage(err)} (and stopping DBOS failed too: ${errorMessage(stopFailed.error)})`,
150
+ { cause: err },
151
+ );
152
+ }
153
+ throw err;
154
+ }
155
+ return worker;
156
+ }
157
+
158
+ /**
159
+ * The workflow's outline, read from its file, which every run is held to. A workflow whose
160
+ * outline cannot be read does not start: nothing could say where its calls are.
161
+ */
162
+ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
163
+ const { outline, source } = outlineWithSource(wf, readSource(wf.file));
164
+ if ("error" in outline) {
165
+ throw new Error(
166
+ `Workflow "${wf.name}" cannot be outlined, so its runs could not be held to its code: ${outline.error}`,
167
+ );
168
+ }
169
+ return { file: outline.file, lineStarts: lineStartsOf(source!), calls: callsOf(outline.nodes) };
170
+ }
171
+
172
+ /**
173
+ * Warns about unfinished runs this worker will never pick up: started on another version of
174
+ * the app, or queued on another queue (such as the single "sanoma" queue before queues were
175
+ * named per app, or the sandbox queue before sandbox runs went on the app's).
176
+ */
177
+ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedConfig) {
178
+ const runs = await DBOS.listWorkflows({
179
+ status: dbosStatusesOf("queued", "running"),
180
+ applicationName: appName,
181
+ loadInput: false,
182
+ loadOutput: false,
183
+ });
184
+ const otherVersion = runs.filter((r) => r.applicationVersion && r.applicationVersion !== version);
185
+ // Recovery moves a worker's own runs to DBOS's internal queues, which are fine.
186
+ const otherQueue = runs.filter(
187
+ (r) => !otherVersion.includes(r) && r.queueName && r.queueName !== queueName && !r.queueName.startsWith("_dbos_"),
188
+ );
189
+ if (!otherVersion.length && !otherQueue.length) return;
190
+ const ids = [...otherVersion, ...otherQueue].map((r) => r.workflowID);
191
+ warn(
192
+ `${otherVersion.length} unfinished run(s) of "${appName}" belong to another version and ${otherQueue.length} ` +
193
+ `wait on another queue, so this worker (version ${version}, queue ${queueName}) will not run them: ` +
194
+ `${ids.slice(0, 5).join(", ")}${ids.length > 5 ? ", …" : ""}. Run the version that started them, or move each ` +
195
+ `with DBOSClient.forkWorkflow(id, step, { applicationVersion: "${version}", queueName: "${queueName}" }) ` +
196
+ `and cancel the original; resumeWorkflow(id, { queueName: "${queueName}" }) moves a run on this version to this queue.`,
197
+ );
198
+ }
199
+
200
+ /** Registers the name with DBOS: its runs take the definition of that name from the running worker. */
201
+ function register(name: string) {
202
+ DBOS.registerWorkflow((args: RunArgs) => quietIfSandbox(args.sandbox !== undefined, () => runWorkflow(name, args)), {
203
+ name,
204
+ });
205
+ }
206
+
207
+ /** One run of the workflow registered as `name`, on the state of the worker running when it starts. */
208
+ async function runWorkflow(name: string, { input, startedBy, sandbox }: RunArgs): Promise<unknown> {
209
+ const state = current;
210
+ // A stopped worker's state stays current while DBOS shuts down.
211
+ if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
212
+ const wf = state.workflows.get(name);
213
+ const outline = state.outlines.get(name);
214
+ // Registered by an earlier worker in this process, whose config had it; this one's has not.
215
+ if (!wf || !outline) throw new Error(`Run of "${name}" refused: this worker's config has no workflow of that name`);
216
+ const run: Run = {
217
+ id: DBOS.workflowID!,
218
+ workflow: wf.name,
219
+ actor: startedBy,
220
+ ...(sandbox === undefined ? {} : { sandbox }),
221
+ approvals: [],
222
+ seq: 0,
223
+ tail: Promise.resolve(),
224
+ inAll: false,
225
+ ended: false,
226
+ outline,
227
+ state,
228
+ };
229
+ await write(run, entry(run, { type: "run.started", input }));
230
+ let output: unknown;
231
+ try {
232
+ try {
233
+ // The one parse of the input: the client checked it, but sent it as given.
234
+ const parsed = parseOrThrow(wf.input, input, `The input does not match ${wf.name}'s schema`);
235
+ if (sandbox !== undefined) {
236
+ // Imported here, so a live worker never loads the Gherkin parser or faker.
237
+ const { seedSandbox } = await import("./sandbox.ts");
238
+ await seedSandbox(run, sandbox);
239
+ }
240
+ output = await wf.run(buildCtx(wf, run), parsed);
241
+ } finally {
242
+ // Before the outcome is written: a call still queued must find the run ended.
243
+ run.ended = true;
244
+ }
245
+ } catch (err) {
246
+ const record = entry(run, { type: "run.failed", error: errorInfo(err) });
247
+ if (isInfrastructureError(err, state)) skipped(record, `the run was interrupted (${errorMessage(err)})`);
248
+ else await writeFailure(run, record, err);
249
+ throw err;
250
+ }
251
+ await write(run, entry(run, { type: "run.finished", output }));
252
+ return output;
253
+ }