@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,34 @@
1
+ import { type SanomaConfig } from "./config.ts";
2
+ export interface Worker {
3
+ /**
4
+ * The vendors whose drivers could not run when the worker started, in the config's order: a
5
+ * live call to one fails, saying why (`unconfigured` in env.ts), until its variables are set.
6
+ * The worker names them in one line at start, unless started `quiet`.
7
+ */
8
+ readonly unconfigured: readonly string[];
9
+ stop(): Promise<void>;
10
+ }
11
+ export interface WorkerOptions {
12
+ /** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
13
+ logLevel?: string;
14
+ /**
15
+ * Start without naming the vendors not configured yet (`unconfigured` still does): for a worker
16
+ * whose runs use the fakes alone, as `runScenarios`'s.
17
+ */
18
+ quiet?: boolean;
19
+ /**
20
+ * Make this worker's version the app's latest, so runs queued without a version come here.
21
+ * A brand-new version becomes the latest on its own; set this only when deliberately starting
22
+ * a worker on a version seen before (a rollback). Off by default, because an old instance
23
+ * restarting during a rolling deploy would otherwise take the new workers' runs.
24
+ */
25
+ promote?: boolean;
26
+ }
27
+ /**
28
+ * Registers the workflows, connects to Postgres and recovers any runs that were interrupted.
29
+ * When a driver declares variables the environment leaves unset, it first loads their stored
30
+ * values into `process.env` and keeps them current while it runs. It starts whether or not they
31
+ * are all there: it prints one line naming the vendors that lack one (`unconfigured`), and a live
32
+ * call to such a vendor fails, saying why, as the call's own final failure, until they are set.
33
+ */
34
+ export declare function startWorker(config: SanomaConfig, options?: WorkerOptions): Promise<Worker>;
package/dist/worker.js ADDED
@@ -0,0 +1,219 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { buildCtx, isInfrastructureError } from "./call.js";
3
+ import { dbosStatusesOf } from "./client.js";
4
+ import { resolveConfig } from "./config.js";
5
+ import { watchCredentials } from "./credentials.js";
6
+ import { DBOS_DATABASE_OPTIONS, prepareDatabase } from "./db.js";
7
+ import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.js";
8
+ import { errorInfo, parseOrThrow } from "./errors.js";
9
+ import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.js";
10
+ import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.js";
11
+ import { lineStartsOf, readSource } from "./ast.js";
12
+ import { callsOf, outlineWithSource } from "./outline.js";
13
+ import { errorMessage } from "./shared.js";
14
+ // DBOS registrations are process-wide and survive shutdown, so a name is registered once, and
15
+ // its function reads the state of the worker running when a run starts, definition and all; the
16
+ // run keeps that state.
17
+ let current;
18
+ const registered = new Set();
19
+ /**
20
+ * Registers the workflows, connects to Postgres and recovers any runs that were interrupted.
21
+ * When a driver declares variables the environment leaves unset, it first loads their stored
22
+ * values into `process.env` and keeps them current while it runs. It starts whether or not they
23
+ * are all there: it prints one line naming the vendors that lack one (`unconfigured`), and a live
24
+ * call to such a vendor fails, saying why, as the call's own final failure, until they are set.
25
+ */
26
+ export async function startWorker(config, options = {}) {
27
+ // Check everything before touching the state a running worker reads.
28
+ const resolved = resolveConfig(config);
29
+ setLogLevel(options.logLevel);
30
+ // The config's own, or a Postgres store made for this worker, which `stop` then closes.
31
+ const ledger = ownedLedger(resolved);
32
+ const state = {
33
+ app: resolved.appName,
34
+ ops: resolved.ops,
35
+ drivers: resolved.drivers,
36
+ fakes: resolved.fakes,
37
+ ...(resolved.scenarios && { scenarios: resolved.scenarios }),
38
+ types: resolved.types,
39
+ steps: resolved.steps,
40
+ policy: resolved.policy,
41
+ ledger: ledger.store,
42
+ workflows: resolved.workflows,
43
+ outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
44
+ stopped: false,
45
+ };
46
+ const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
47
+ const worker = {
48
+ unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
49
+ .filter(([vendor, statuses]) => unconfigured(vendor, statuses))
50
+ .map(([vendor]) => vendor),
51
+ async stop() {
52
+ state.stopped = true;
53
+ // Each is closed whatever the one before did; the first failure is what stop throws.
54
+ let failed;
55
+ for (const close of [() => DBOS.shutdown(), () => credentials.close(), () => ledger.close()]) {
56
+ await close().catch((error) => (failed ??= { error }));
57
+ }
58
+ if (failed)
59
+ throw failed.error;
60
+ },
61
+ };
62
+ // Runs recovered during launch start before it returns, so they must already find this
63
+ // state. If starting fails, a worker still running in the process gets its own back, once
64
+ // DBOS has stopped: until then a run DBOS dispatches finds this state stopped, and is refused.
65
+ const previous = current;
66
+ let launched = false;
67
+ try {
68
+ for (const name of resolved.workflows.keys()) {
69
+ if (!registered.has(name)) {
70
+ register(name);
71
+ registered.add(name);
72
+ }
73
+ }
74
+ current = state;
75
+ // Before DBOS's pool connects, so its connections start with the database's idle-transaction
76
+ // timeout (`DBOS_DATABASE_OPTIONS`); once per process and database.
77
+ await prepareDatabase(resolved.databaseUrl);
78
+ DBOS.setConfig({
79
+ name: resolved.appName,
80
+ systemDatabaseUrl: resolved.databaseUrl,
81
+ ...DBOS_DATABASE_OPTIONS,
82
+ // A logger of our own replaces DBOS's OTLP log export, so only while DBOS exports none (it
83
+ // does on DBOS Cloud); its level is then the threshold `setLogLevel` set, DBOS's own `logLevel` unread.
84
+ ...(process.env.DBOS__CLOUD === "true" ? { logLevel: options.logLevel ?? "warn" } : { logger: dbosLogger }),
85
+ applicationVersion: resolved.version,
86
+ });
87
+ await DBOS.launch();
88
+ launched = true;
89
+ // DBOS gives runs queued without a version only to the app's latest version, which is the
90
+ // newest one registered. A worker started on code seen before (a rollback) is not it.
91
+ const latest = (await DBOS.getLatestApplicationVersion()).versionName;
92
+ if (latest !== resolved.version) {
93
+ if (options.promote)
94
+ await DBOS.setLatestApplicationVersion(resolved.version);
95
+ else {
96
+ warn(`this worker runs version ${resolved.version} but the app's latest is ${latest}; ` +
97
+ "runs queued without a version go to the latest. Start with { promote: true } to take them here");
98
+ }
99
+ }
100
+ await DBOS.registerQueue(resolved.queueName);
101
+ await warnAboutStrandedRuns(resolved);
102
+ const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
103
+ if (summary && !options.quiet)
104
+ warn(summary);
105
+ }
106
+ catch (err) {
107
+ // Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
108
+ // in a worker nobody holds, and the next startWorker launches afresh.
109
+ let stopFailed;
110
+ if (launched)
111
+ await worker.stop().catch((error) => (stopFailed = { error }));
112
+ else {
113
+ await credentials
114
+ .close()
115
+ .catch((e) => warn(`closing the credentials listener failed: ${errorMessage(e)}`));
116
+ }
117
+ current = previous;
118
+ if (stopFailed) {
119
+ throw new AggregateError([err, stopFailed.error], `${errorMessage(err)} (and stopping DBOS failed too: ${errorMessage(stopFailed.error)})`, { cause: err });
120
+ }
121
+ throw err;
122
+ }
123
+ return worker;
124
+ }
125
+ /**
126
+ * The workflow's outline, read from its file, which every run is held to. A workflow whose
127
+ * outline cannot be read does not start: nothing could say where its calls are.
128
+ */
129
+ function outlineOf(wf) {
130
+ const { outline, source } = outlineWithSource(wf, readSource(wf.file));
131
+ if ("error" in outline) {
132
+ throw new Error(`Workflow "${wf.name}" cannot be outlined, so its runs could not be held to its code: ${outline.error}`);
133
+ }
134
+ return { file: outline.file, lineStarts: lineStartsOf(source), calls: callsOf(outline.nodes) };
135
+ }
136
+ /**
137
+ * Warns about unfinished runs this worker will never pick up: started on another version of
138
+ * the app, or queued on another queue (such as the single "sanoma" queue before queues were
139
+ * named per app, or the sandbox queue before sandbox runs went on the app's).
140
+ */
141
+ async function warnAboutStrandedRuns({ appName, version, queueName }) {
142
+ const runs = await DBOS.listWorkflows({
143
+ status: dbosStatusesOf("queued", "running"),
144
+ applicationName: appName,
145
+ loadInput: false,
146
+ loadOutput: false,
147
+ });
148
+ const otherVersion = runs.filter((r) => r.applicationVersion && r.applicationVersion !== version);
149
+ // Recovery moves a worker's own runs to DBOS's internal queues, which are fine.
150
+ const otherQueue = runs.filter((r) => !otherVersion.includes(r) && r.queueName && r.queueName !== queueName && !r.queueName.startsWith("_dbos_"));
151
+ if (!otherVersion.length && !otherQueue.length)
152
+ return;
153
+ const ids = [...otherVersion, ...otherQueue].map((r) => r.workflowID);
154
+ warn(`${otherVersion.length} unfinished run(s) of "${appName}" belong to another version and ${otherQueue.length} ` +
155
+ `wait on another queue, so this worker (version ${version}, queue ${queueName}) will not run them: ` +
156
+ `${ids.slice(0, 5).join(", ")}${ids.length > 5 ? ", …" : ""}. Run the version that started them, or move each ` +
157
+ `with DBOSClient.forkWorkflow(id, step, { applicationVersion: "${version}", queueName: "${queueName}" }) ` +
158
+ `and cancel the original; resumeWorkflow(id, { queueName: "${queueName}" }) moves a run on this version to this queue.`);
159
+ }
160
+ /** Registers the name with DBOS: its runs take the definition of that name from the running worker. */
161
+ function register(name) {
162
+ DBOS.registerWorkflow((args) => quietIfSandbox(args.sandbox !== undefined, () => runWorkflow(name, args)), {
163
+ name,
164
+ });
165
+ }
166
+ /** One run of the workflow registered as `name`, on the state of the worker running when it starts. */
167
+ async function runWorkflow(name, { input, startedBy, sandbox }) {
168
+ const state = current;
169
+ // A stopped worker's state stays current while DBOS shuts down.
170
+ if (!state || state.stopped)
171
+ throw new Error(`Run of "${name}" started with no worker running`);
172
+ const wf = state.workflows.get(name);
173
+ const outline = state.outlines.get(name);
174
+ // Registered by an earlier worker in this process, whose config had it; this one's has not.
175
+ if (!wf || !outline)
176
+ throw new Error(`Run of "${name}" refused: this worker's config has no workflow of that name`);
177
+ const run = {
178
+ id: DBOS.workflowID,
179
+ workflow: wf.name,
180
+ actor: startedBy,
181
+ ...(sandbox === undefined ? {} : { sandbox }),
182
+ approvals: [],
183
+ seq: 0,
184
+ tail: Promise.resolve(),
185
+ inAll: false,
186
+ ended: false,
187
+ outline,
188
+ state,
189
+ };
190
+ await write(run, entry(run, { type: "run.started", input }));
191
+ let output;
192
+ try {
193
+ try {
194
+ // The one parse of the input: the client checked it, but sent it as given.
195
+ const parsed = parseOrThrow(wf.input, input, `The input does not match ${wf.name}'s schema`);
196
+ if (sandbox !== undefined) {
197
+ // Imported here, so a live worker never loads the Gherkin parser or faker.
198
+ const { seedSandbox } = await import("./sandbox.js");
199
+ await seedSandbox(run, sandbox);
200
+ }
201
+ output = await wf.run(buildCtx(wf, run), parsed);
202
+ }
203
+ finally {
204
+ // Before the outcome is written: a call still queued must find the run ended.
205
+ run.ended = true;
206
+ }
207
+ }
208
+ catch (err) {
209
+ const record = entry(run, { type: "run.failed", error: errorInfo(err) });
210
+ if (isInfrastructureError(err, state))
211
+ skipped(record, `the run was interrupted (${errorMessage(err)})`);
212
+ else
213
+ await writeFailure(run, record, err);
214
+ throw err;
215
+ }
216
+ await write(run, entry(run, { type: "run.finished", output }));
217
+ return output;
218
+ }
219
+ //# sourceMappingURL=worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worker.js","sourceRoot":"","sources":["../src/worker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAC1C,OAAO,EAAE,QAAQ,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,EAAuB,aAAa,EAAqB,MAAM,aAAa,CAAC;AACpF,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,EAAE,qBAAqB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAEjE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC5E,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC/E,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAE1D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAY3C,8FAA8F;AAC9F,gGAAgG;AAChG,wBAAwB;AACxB,IAAI,OAAgC,CAAC;AACrC,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;AAmBrC;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,MAAoB,EAAE,OAAO,GAAkB,EAAE;IACjF,qEAAqE;IACrE,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC,WAAW,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9B,wFAAwF;IACxF,MAAM,MAAM,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC;IACrC,MAAM,KAAK,GAAgB;QACzB,GAAG,EAAE,QAAQ,CAAC,OAAO;QACrB,GAAG,EAAE,QAAQ,CAAC,GAAG;QACjB,OAAO,EAAE,QAAQ,CAAC,OAAO;QACzB,KAAK,EAAE,QAAQ,CAAC,KAAK;QACrB,GAAG,CAAC,QAAQ,CAAC,SAAS,IAAI,EAAE,SAAS,EAAE,QAAQ,CAAC,SAAS,EAAE,CAAC;QAC5D,KAAK,EAAE,QAAQ,CAAC,KAAK;QACrB,KAAK,EAAE,QAAQ,CAAC,KAAK;QACrB,MAAM,EAAE,QAAQ,CAAC,MAAM;QACvB,MAAM,EAAE,MAAM,CAAC,KAAK;QACpB,SAAS,EAAE,QAAQ,CAAC,SAAS;QAC7B,QAAQ,EAAE,IAAI,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;QACrF,OAAO,EAAE,KAAK;KACf,CAAC;IACF,MAAM,WAAW,GAAG,MAAM,gBAAgB,CAAC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,eAAe,CAAC,CAAC;IAC3F,MAAM,MAAM,GAAW;QACrB,YAAY,EAAE,CAAC,GAAG,aAAa,CAAC,QAAQ,CAAC,eAAe,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC;aACpF,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;aAC9D,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC;QAC5B,KAAK,CAAC,IAAI;YACR,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC;YACrB,qFAAqF;YACrF,IAAI,MAAsC,CAAC;YAC3C,KAAK,MAAM,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,QAAQ,EAAE,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,EAAE,CAAC;gBAC7F,MAAM,KAAK,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE,CAAC,CAAC,MAAM,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;YAClE,CAAC;YACD,IAAI,MAAM;gBAAE,MAAM,MAAM,CAAC,KAAK,CAAC;QACjC,CAAC;KACF,CAAC;IACF,uFAAuF;IACvF,0FAA0F;IAC1F,+FAA+F;IAC/F,MAAM,QAAQ,GAAG,OAAO,CAAC;IACzB,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,CAAC;QACH,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,SAAS,CAAC,IAAI,EAAE,EAAE,CAAC;YAC7C,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1B,QAAQ,CAAC,IAAI,CAAC,CAAC;gBACf,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QACD,OAAO,GAAG,KAAK,CAAC;QAChB,6FAA6F;QAC7F,oEAAoE;QACpE,MAAM,eAAe,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QAC5C,IAAI,CAAC,SAAS,CAAC;YACb,IAAI,EAAE,QAAQ,CAAC,OAAO;YACtB,iBAAiB,EAAE,QAAQ,CAAC,WAAW;YACvC,GAAG,qBAAqB;YACxB,2FAA2F;YAC3F,wGAAwG;YACxG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,KAAK,MAAM,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,IAAI,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;YAC3G,kBAAkB,EAAE,QAAQ,CAAC,OAAO;SACrC,CAAC,CAAC;QACH,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;QACpB,QAAQ,GAAG,IAAI,CAAC;QAChB,0FAA0F;QAC1F,sFAAsF;QACtF,MAAM,MAAM,GAAG,CAAC,MAAM,IAAI,CAAC,2BAA2B,EAAE,CAAC,CAAC,WAAW,CAAC;QACtE,IAAI,MAAM,KAAK,QAAQ,CAAC,OAAO,EAAE,CAAC;YAChC,IAAI,OAAO,CAAC,OAAO;gBAAE,MAAM,IAAI,CAAC,2BAA2B,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;iBACzE,CAAC;gBACJ,IAAI,CACF,4BAA4B,QAAQ,CAAC,OAAO,4BAA4B,MAAM,IAAI;oBAChF,gGAAgG,CACnG,CAAC;YACJ,CAAC;QACH,CAAC;QACD,MAAM,IAAI,CAAC,aAAa,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC;QAC7C,MAAM,qBAAqB,CAAC,QAAQ,CAAC,CAAC;QACtC,MAAM,OAAO,GAAG,mBAAmB,CAAC,MAAM,CAAC,YAAY,EAAE,QAAQ,CAAC,eAAe,CAAC,IAAI,CAAC,CAAC;QACxF,IAAI,OAAO,IAAI,CAAC,OAAO,CAAC,KAAK;YAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,4FAA4F;QAC5F,sEAAsE;QACtE,IAAI,UAA0C,CAAC;QAC/C,IAAI,QAAQ;YAAE,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE,CAAC,CAAC,UAAU,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;aACjF,CAAC;YACJ,MAAM,WAAW;iBACd,KAAK,EAAE;iBACP,KAAK,CAAC,CAAC,CAAU,EAAE,EAAE,CAAC,IAAI,CAAC,4CAA4C,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QAChG,CAAC;QACD,OAAO,GAAG,QAAQ,CAAC;QACnB,IAAI,UAAU,EAAE,CAAC;YACf,MAAM,IAAI,cAAc,CACtB,CAAC,GAAG,EAAE,UAAU,CAAC,KAAK,CAAC,EACvB,GAAG,YAAY,CAAC,GAAG,CAAC,mCAAmC,YAAY,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,EACxF,EAAE,KAAK,EAAE,GAAG,EAAE,CACf,CAAC;QACJ,CAAC;QACD,MAAM,GAAG,CAAC;IACZ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;GAGG;AACH,SAAS,SAAS,CAAC,EAAgC;IACjD,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,iBAAiB,CAAC,EAAE,EAAE,UAAU,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;IACvE,IAAI,OAAO,IAAI,OAAO,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,aAAa,EAAE,CAAC,IAAI,oEAAoE,OAAO,CAAC,KAAK,EAAE,CACxG,CAAC;IACJ,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,YAAY,CAAC,MAAO,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;AAClG,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,qBAAqB,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,SAAS,EAAkB;IAClF,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC;QACpC,MAAM,EAAE,cAAc,CAAC,QAAQ,EAAE,SAAS,CAAC;QAC3C,eAAe,EAAE,OAAO;QACxB,SAAS,EAAE,KAAK;QAChB,UAAU,EAAE,KAAK;KAClB,CAAC,CAAC;IACH,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,kBAAkB,IAAI,CAAC,CAAC,kBAAkB,KAAK,OAAO,CAAC,CAAC;IAClG,gFAAgF;IAChF,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,CAC5B,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,QAAQ,CAAC,CAClH,CAAC;IACF,IAAI,CAAC,YAAY,CAAC,MAAM,IAAI,CAAC,UAAU,CAAC,MAAM;QAAE,OAAO;IACvD,MAAM,GAAG,GAAG,CAAC,GAAG,YAAY,EAAE,GAAG,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC;IACtE,IAAI,CACF,GAAG,YAAY,CAAC,MAAM,0BAA0B,OAAO,mCAAmC,UAAU,CAAC,MAAM,GAAG;QAC5G,kDAAkD,OAAO,WAAW,SAAS,uBAAuB;QACpG,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,oDAAoD;QAC/G,iEAAiE,OAAO,kBAAkB,SAAS,OAAO;QAC1G,6DAA6D,SAAS,iDAAiD,CAC1H,CAAC;AACJ,CAAC;AAED,uGAAuG;AACvG,SAAS,QAAQ,CAAC,IAAY;IAC5B,IAAI,CAAC,gBAAgB,CAAC,CAAC,IAAa,EAAE,EAAE,CAAC,cAAc,CAAC,IAAI,CAAC,OAAO,KAAK,SAAS,EAAE,GAAG,EAAE,CAAC,WAAW,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,EAAE;QAClH,IAAI;KACL,CAAC,CAAC;AACL,CAAC;AAED,uGAAuG;AACvG,KAAK,UAAU,WAAW,CAAC,IAAY,EAAE,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAW;IAC7E,MAAM,KAAK,GAAG,OAAO,CAAC;IACtB,gEAAgE;IAChE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,kCAAkC,CAAC,CAAC;IAChG,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,OAAO,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IACzC,4FAA4F;IAC5F,IAAI,CAAC,EAAE,IAAI,CAAC,OAAO;QAAE,MAAM,IAAI,KAAK,CAAC,WAAW,IAAI,8DAA8D,CAAC,CAAC;IACpH,MAAM,GAAG,GAAQ;QACf,EAAE,EAAE,IAAI,CAAC,UAAW;QACpB,QAAQ,EAAE,EAAE,CAAC,IAAI;QACjB,KAAK,EAAE,SAAS;QAChB,GAAG,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC;QAC7C,SAAS,EAAE,EAAE;QACb,GAAG,EAAE,CAAC;QACN,IAAI,EAAE,OAAO,CAAC,OAAO,EAAE;QACvB,KAAK,EAAE,KAAK;QACZ,KAAK,EAAE,KAAK;QACZ,OAAO;QACP,KAAK;KACN,CAAC;IACF,MAAM,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC;IAC7D,IAAI,MAAe,CAAC;IACpB,IAAI,CAAC;QACH,IAAI,CAAC;YACH,2EAA2E;YAC3E,MAAM,MAAM,GAAG,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,4BAA4B,EAAE,CAAC,IAAI,WAAW,CAAC,CAAC;YAC7F,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBAC1B,2EAA2E;gBAC3E,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,MAAM,CAAC,cAAc,CAAC,CAAC;gBACrD,MAAM,WAAW,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAClC,CAAC;YACD,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;QACnD,CAAC;gBAAS,CAAC;YACT,8EAA8E;YAC9E,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC;QACnB,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACzE,IAAI,qBAAqB,CAAC,GAAG,EAAE,KAAK,CAAC;YAAE,OAAO,CAAC,MAAM,EAAE,4BAA4B,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;;YACpG,MAAM,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;QAC1C,MAAM,GAAG,CAAC;IACZ,CAAC;IACD,MAAM,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;IAC/D,OAAO,MAAM,CAAC;AAChB,CAAC"}
package/oxlint.json ADDED
@@ -0,0 +1,128 @@
1
+ {
2
+ "overrides": [
3
+ {
4
+ "files": ["workflows/**", "policies/**"],
5
+ "rules": {
6
+ "no-restricted-globals": [
7
+ "error",
8
+ {
9
+ "name": "Date",
10
+ "message": "Workflows are replayed: use `await ctx.now()` for the time and `ctx.sleep({ until })` to wait for a date."
11
+ },
12
+ {
13
+ "name": "fetch",
14
+ "message": "A workflow must call vendors through ctx, so the policy sees every call: use a connector operation listed in `uses`."
15
+ },
16
+ {
17
+ "name": "WebSocket",
18
+ "message": "A workflow must call vendors through ctx, so the policy sees every call: use a connector operation listed in `uses`."
19
+ },
20
+ {
21
+ "name": "XMLHttpRequest",
22
+ "message": "A workflow must call vendors through ctx, so the policy sees every call: use a connector operation listed in `uses`."
23
+ },
24
+ { "name": "setTimeout", "message": "Workflows are replayed: use `ctx.sleep`." },
25
+ { "name": "setInterval", "message": "Workflows are replayed: use `ctx.sleep` in a loop." },
26
+ { "name": "setImmediate", "message": "Workflows are replayed: use `ctx.sleep`." },
27
+ { "name": "queueMicrotask", "message": "Workflows are replayed: await the work directly." },
28
+ { "name": "performance", "message": "Workflows are replayed: use `await ctx.now()`." },
29
+ {
30
+ "name": "process",
31
+ "message": "Workflows are replayed: pass configuration as workflow input; drivers read the environment."
32
+ },
33
+ { "name": "crypto", "message": "Workflows are replayed: derive ids from `ctx.runId`." },
34
+ {
35
+ "name": "require",
36
+ "message": "Import from `@sanoma-ai/workflows`, a `@sanoma-ai/connector-*` package, zod or a file in this directory."
37
+ },
38
+ { "name": "eval", "message": "Not allowed in a workflow or policy." },
39
+ { "name": "Function", "message": "Not allowed in a workflow or policy." },
40
+ {
41
+ "name": "globalThis",
42
+ "message": "Reaching globals through globalThis would bypass these rules: use `ctx`."
43
+ },
44
+ { "name": "global", "message": "Reaching globals through global would bypass these rules: use `ctx`." }
45
+ ],
46
+ "no-restricted-properties": [
47
+ "error",
48
+ {
49
+ "object": "Math",
50
+ "property": "random",
51
+ "message": "Workflows are replayed: derive values from `ctx.runId` or the input."
52
+ },
53
+ {
54
+ "object": "Promise",
55
+ "property": "all",
56
+ "message": "Calls through ctx run one at a time; declare a fan-out with `ctx.all([...])` so the ledger and the graph show it."
57
+ },
58
+ {
59
+ "object": "Promise",
60
+ "property": "allSettled",
61
+ "message": "Calls through ctx run one at a time; declare a fan-out with `ctx.all([...])` so the ledger and the graph show it."
62
+ },
63
+ {
64
+ "object": "Promise",
65
+ "property": "race",
66
+ "message": "Calls through ctx run one at a time, so nothing races: pick one call, or sleep and then check."
67
+ },
68
+ {
69
+ "object": "Promise",
70
+ "property": "any",
71
+ "message": "Calls through ctx run one at a time, so nothing races: pick one call, or sleep and then check."
72
+ }
73
+ ],
74
+ "no-restricted-imports": [
75
+ "error",
76
+ {
77
+ "paths": [
78
+ {
79
+ "name": "@sanoma-ai/testing",
80
+ "message": "Fakes and test helpers belong in tests. A workflow must call vendors through ctx, so the policy sees every call."
81
+ },
82
+ {
83
+ "name": "@sanoma-ai/app",
84
+ "message": "The app starts runs and decides approvals: a workflow could approve itself."
85
+ },
86
+ {
87
+ "name": "@sanoma-ai/workflows",
88
+ "allowImportNames": [
89
+ "defineWorkflow",
90
+ "definePolicy",
91
+ "allow",
92
+ "deny",
93
+ "approve",
94
+ "approvedFor",
95
+ "allowAll",
96
+ "mayDecide",
97
+ "errorCode",
98
+ "z"
99
+ ],
100
+ "allowTypeImports": true,
101
+ "message": "A workflow or policy imports only these and types from @sanoma-ai/workflows: the rest (SanomaClient, startWorker, the ledger stores, config helpers) could start runs, approve its own approvals, forge the ledger or read credentials. Ask for approval with `ctx.approval`."
102
+ }
103
+ ],
104
+ "patterns": [
105
+ {
106
+ "group": [
107
+ "@sanoma-ai/connector-*/fake",
108
+ "@sanoma-ai/connector-*/driver",
109
+ "@sanoma-ai/workflows/describe",
110
+ "@sanoma-ai/workflows/fake",
111
+ "@sanoma-ai/workflows/plugin",
112
+ "@sanoma-ai/workflows/scenario",
113
+ "@sanoma-ai/workflows/scenarios"
114
+ ],
115
+ "message": "A workflow must call vendors through ctx, so the policy sees every call: import the connector itself, `@sanoma-ai/connector-<vendor>`."
116
+ }
117
+ ]
118
+ }
119
+ ]
120
+ }
121
+ },
122
+ {
123
+ "files": ["resources/**"],
124
+ "jsPlugins": ["./dist/plugin.js"],
125
+ "rules": { "sanoma/data-file": "error" }
126
+ }
127
+ ]
128
+ }
package/package.json CHANGED
@@ -1,6 +1,94 @@
1
1
  {
2
2
  "name": "@sanoma-ai/workflows",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Business processes as TypeScript functions, run durably on DBOS",
5
+ "keywords": [
6
+ "approvals",
7
+ "business-as-code",
8
+ "dbos",
9
+ "durable-execution",
10
+ "sanoma",
11
+ "workflows"
12
+ ],
13
+ "homepage": "https://github.com/Sanoma-AI/sanoma/tree/main/packages/workflows#readme",
14
+ "bugs": "https://github.com/Sanoma-AI/sanoma/issues",
15
+ "license": "FSL-1.1-ALv2",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Sanoma-AI/sanoma.git",
19
+ "directory": "packages/workflows"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src",
24
+ "!src/AGENTS.md",
25
+ "oxlint.json",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./describe": {
39
+ "types": "./dist/describe.d.ts",
40
+ "default": "./dist/describe.js"
41
+ },
42
+ "./fake": {
43
+ "types": "./dist/fake.d.ts",
44
+ "default": "./dist/fake.js"
45
+ },
46
+ "./plugin": {
47
+ "types": "./dist/plugin.d.ts",
48
+ "default": "./dist/plugin.js"
49
+ },
50
+ "./scenario": {
51
+ "types": "./dist/scenario.d.ts",
52
+ "default": "./dist/scenario.js"
53
+ },
54
+ "./scenarios": {
55
+ "types": "./dist/scenarios.d.ts",
56
+ "default": "./dist/scenarios.js"
57
+ },
58
+ "./shared": {
59
+ "types": "./dist/shared.d.ts",
60
+ "default": "./dist/shared.js"
61
+ }
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "dependencies": {
67
+ "@cucumber/cucumber": "13.3.0",
68
+ "@cucumber/cucumber-expressions": "^20.1.0",
69
+ "@cucumber/messages": "^34.2.1",
70
+ "@cucumber/query": "16.1.1",
71
+ "@dbos-inc/dbos-sdk": "^5.2.11",
72
+ "@faker-js/faker": "^10.6.0",
73
+ "oxc-parser": "^0.153.0",
74
+ "pg": "^8.23.1",
75
+ "randexp": "^0.5.3",
76
+ "zod-schema-faker": "^2.1.2"
77
+ },
78
+ "devDependencies": {
79
+ "@types/pg": "^8.23.1",
80
+ "zod": "^4.6.5"
81
+ },
82
+ "peerDependencies": {
83
+ "zod": "^4"
84
+ },
85
+ "engines": {
86
+ "node": "24 || >=26"
87
+ },
88
+ "//": "randexp is a dependency only because zod-schema-faker, which makes up scenario inputs, needs it as a peer.",
89
+ "scripts": {
90
+ "build": "tsc -p tsconfig.build.json",
91
+ "clean": "rm -rf dist",
92
+ "watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
93
+ }
6
94
  }
@@ -0,0 +1,157 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { z } from "zod";
3
+ import { type ApprovalRequest, type ApprovalResult, type ApprovalState, notApprover, Principal } from "./define.ts";
4
+ import { RejectedError } from "./errors.ts";
5
+ import { entry, write } from "./ledger.ts";
6
+ import { warn } from "./log.ts";
7
+ import type { Placed } from "./call.ts";
8
+ import type { Run } from "./run.ts";
9
+ import { approverLabel, mayDecide } from "./shared.ts";
10
+
11
+ // The wire contract between a run waiting on approvals and whoever decides them. The client
12
+ // imports this file and never the worker's.
13
+
14
+ /** The DBOS event a run publishes its approvals on, as `ApprovalState[]`. */
15
+ export const APPROVALS_EVENT = "approvals";
16
+ /** The `recv` topic a decision on an approval is sent to. */
17
+ export const topicOf = (approvalId: string) => approvalId;
18
+ /** The DBOS event an approval's decision is published on, as its decided `ApprovalState`, once made. */
19
+ export const decisionEventOf = (approvalId: string) => `approval:${approvalId}`;
20
+ /**
21
+ * The idempotency key a decision message is sent with, so a retried send queues it once. DBOS
22
+ * scopes the key per run, not per topic, so it names the approval too: one message id reused
23
+ * for two approvals of a run would otherwise drop the second send.
24
+ */
25
+ export const messageKeyOf = (approvalId: string, messageId: string) => `${approvalId}:${messageId}`;
26
+
27
+ /**
28
+ * A decision, as sent to a run. `id` names the message, so whoever sent it can tell whether
29
+ * the run decided with it or with another; `SanomaClient.decide` sets one.
30
+ */
31
+ export const ApprovalMessage = z.object({
32
+ id: z.string().optional(),
33
+ decision: z.enum(["approve", "reject"]),
34
+ by: Principal,
35
+ note: z.string().optional(),
36
+ });
37
+ export type ApprovalMessage = z.infer<typeof ApprovalMessage>;
38
+
39
+ const statusOf = (decision: ApprovalMessage["decision"]) => (decision === "approve" ? "approved" : "rejected");
40
+
41
+ /** The sender's id, from a message that may not be a valid decision. */
42
+ function senderOf(raw: unknown): string | undefined {
43
+ const by = typeof raw === "object" && raw !== null ? (raw as { by?: unknown }).by : undefined;
44
+ const id = typeof by === "object" && by !== null ? (by as { id?: unknown }).id : undefined;
45
+ return typeof id === "string" && id ? id : undefined;
46
+ }
47
+
48
+ /** An approval request as checked: who may decide it, and the operations it covers, by id. */
49
+ export interface CheckedApproval extends Omit<ApprovalRequest, "covers"> {
50
+ covers: string[];
51
+ }
52
+
53
+ /** A policy's hold: the call held, and the ledger `seq` its `op.called` record takes. */
54
+ export interface HeldCall {
55
+ op: string;
56
+ seq: number;
57
+ input: unknown;
58
+ }
59
+
60
+ /**
61
+ * Waits, durably, for a decision from someone who may make it. Throws `RejectedError` on a
62
+ * rejection. Without `held`, the workflow asked (`ctx.approval`); with it, the policy did, and
63
+ * `place` is the held call's outline node and name.
64
+ */
65
+ export async function awaitApproval(
66
+ run: Run,
67
+ title: string,
68
+ req: CheckedApproval,
69
+ place: Placed,
70
+ held?: HeldCall,
71
+ ): Promise<ApprovalResult> {
72
+ const { covers } = req;
73
+ const all = run.approvals;
74
+ const state: ApprovalState = {
75
+ id: `approval-${all.length + 1}`,
76
+ title,
77
+ approver: req.approver,
78
+ ...(req.links === undefined ? {} : { links: req.links }),
79
+ ...(req.details === undefined ? {} : { details: req.details }),
80
+ requestedBy: held ? "policy" : "workflow",
81
+ covers,
82
+ ...(held ? { op: held.op, opSeq: held.seq, input: held.input } : {}),
83
+ status: "pending",
84
+ requestedAt: 0,
85
+ refused: [],
86
+ };
87
+ all.push(state);
88
+ state.requestedAt = await DBOS.now();
89
+ await DBOS.setEvent(APPROVALS_EVENT, all);
90
+ await write(
91
+ run,
92
+ entry(
93
+ run,
94
+ {
95
+ type: "approval.requested",
96
+ approval: state.id,
97
+ ...place,
98
+ title,
99
+ approver: state.approver,
100
+ requestedBy: state.requestedBy,
101
+ covers,
102
+ ...(held ? { op: held.op, opSeq: held.seq } : {}),
103
+ },
104
+ { key: state.id, at: state.requestedAt },
105
+ ),
106
+ );
107
+ for (;;) {
108
+ const raw = await DBOS.recv<unknown>(topicOf(state.id), { timeoutSeconds: 24 * 60 * 60 });
109
+ if (raw === null || raw === undefined) {
110
+ warn(`run ${run.id}: approval ${state.id} ("${title}") is still waiting for ${approverLabel(state.approver)}`);
111
+ continue;
112
+ }
113
+ const at = await DBOS.now();
114
+ const parsed = ApprovalMessage.safeParse(raw);
115
+ const msg = parsed.success ? parsed.data : undefined;
116
+ if (!msg || !mayDecide(state, msg.by)) {
117
+ const by = msg?.by.id ?? senderOf(raw);
118
+ const reason = msg ? notApprover(state, msg.by) : "not a valid decision message";
119
+ state.refused.push({
120
+ ...(by === undefined ? {} : { by }),
121
+ ...(msg?.id === undefined ? {} : { id: msg.id }),
122
+ at,
123
+ reason,
124
+ });
125
+ await DBOS.setEvent(APPROVALS_EVENT, all);
126
+ await write(
127
+ run,
128
+ entry(
129
+ run,
130
+ { type: "approval.refused", approval: state.id, ...(by === undefined ? {} : { by }), reason },
131
+ { key: `${state.id}:refused:${state.refused.length}`, at },
132
+ ),
133
+ );
134
+ continue;
135
+ }
136
+ Object.assign(state, {
137
+ status: statusOf(msg.decision),
138
+ decidedBy: msg.by.id,
139
+ decidedAt: at,
140
+ note: msg.note,
141
+ decidedWith: msg.id,
142
+ });
143
+ // The list first, then the ledger, then the decision event: whoever sees the event sees both.
144
+ await DBOS.setEvent(APPROVALS_EVENT, all);
145
+ await write(
146
+ run,
147
+ entry(
148
+ run,
149
+ { type: "approval.decided", approval: state.id, decision: msg.decision, by: msg.by.id, note: msg.note },
150
+ { key: state.id, at },
151
+ ),
152
+ );
153
+ await DBOS.setEvent(decisionEventOf(state.id), state);
154
+ if (msg.decision === "reject") throw new RejectedError(title, msg.by, msg.note, state.id);
155
+ return { approvedBy: msg.by.id, at, note: msg.note };
156
+ }
157
+ }