@sanoma-ai/workflows 0.0.0-stage → 0.2.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 (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
package/src/client.ts ADDED
@@ -0,0 +1,604 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
3
+ import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
4
+ import type { Pool } from "pg";
5
+ import { z } from "zod";
6
+ import { APPROVALS_EVENT, ApprovalMessage, decisionEventOf, messageKeyOf, topicOf } from "./approvals.ts";
7
+ import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
8
+ import { deleteCredential, storeCredential, storedCredentials } from "./credentials.ts";
9
+ import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
10
+ import { type CredentialStatus, credentialsOf } from "./env.ts";
11
+ import { type ApprovalState, notApprover, Principal, type WorkflowDefinition } from "./define.ts";
12
+ import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
13
+ import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
14
+ import { parseOrThrow, SanomaError } from "./errors.ts";
15
+ import { type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
16
+ import type { Driver } from "./op.ts";
17
+ import type { RunArgs } from "./run.ts";
18
+ import { isEnded, mayDecide, type RunStatus } from "./shared.ts";
19
+
20
+ /**
21
+ * The run status each DBOS status maps to. `waiting` is PENDING with an approval pending, so
22
+ * only the approvals tell it from `running`. Keyed by every DBOS status, so one a later DBOS
23
+ * adds fails to compile here until it is mapped.
24
+ */
25
+ const FROM_DBOS: Record<WorkflowStatusString, Exclude<RunStatus, "waiting">> = {
26
+ ENQUEUED: "queued",
27
+ DELAYED: "queued",
28
+ PENDING: "running",
29
+ SUCCESS: "finished",
30
+ ERROR: "failed",
31
+ MAX_RECOVERY_ATTEMPTS_EXCEEDED: "failed",
32
+ CANCELLED: "cancelled",
33
+ };
34
+
35
+ /** A run's status from DBOS's, and whether it has an approval pending. */
36
+ export function runStatus(dbosStatus: string, approvals: readonly ApprovalState[]): RunStatus {
37
+ // A status this DBOS did not have when the table was written: not ended as far as we know.
38
+ const status = Object.hasOwn(FROM_DBOS, dbosStatus) ? FROM_DBOS[dbosStatus as WorkflowStatusString] : "running";
39
+ return status === "running" && approvals.some((a) => a.status === "pending") ? "waiting" : status;
40
+ }
41
+
42
+ /** The DBOS statuses the run statuses come from; `running` and `waiting` both come from PENDING. */
43
+ export const dbosStatusesOf = (...statuses: RunStatus[]): WorkflowStatusString[] =>
44
+ (Object.keys(FROM_DBOS) as WorkflowStatusString[]).filter((s) =>
45
+ statuses.some((status) => FROM_DBOS[s] === (status === "waiting" ? "running" : status)),
46
+ );
47
+
48
+ /**
49
+ * The first `limit` items `keep` accepts, read a page of `pageSize` at a time from `list`, in
50
+ * its order, until there are enough or a page comes back short (the last one).
51
+ */
52
+ export async function firstMatching<T>(
53
+ list: (page: { limit: number; offset: number }) => Promise<T[]>,
54
+ keep: (item: T) => boolean,
55
+ limit: number,
56
+ pageSize: number,
57
+ ): Promise<T[]> {
58
+ const found: T[] = [];
59
+ for (let offset = 0; ; offset += pageSize) {
60
+ const rows = await list({ limit: pageSize, offset });
61
+ found.push(...rows.filter(keep));
62
+ if (found.length >= limit || rows.length < pageSize) return found.slice(0, limit);
63
+ }
64
+ }
65
+
66
+ /** Which runs `SanomaClient.runs` lists. */
67
+ export interface RunsFilter {
68
+ /** At most this many, newest first. Defaults to 20. */
69
+ limit?: number;
70
+ /** Only runs with this status. */
71
+ status?: RunStatus;
72
+ /** Only runs of the workflow of this name. */
73
+ workflow?: string;
74
+ }
75
+
76
+ export interface RunSummary {
77
+ runId: string;
78
+ workflow: string;
79
+ status: RunStatus;
80
+ /** Who started the run, as passed to `start`. */
81
+ startedBy?: Principal;
82
+ createdAt: number;
83
+ updatedAt?: number;
84
+ approvals: ApprovalState[];
85
+ error?: string;
86
+ /** For a sandbox run, the scenario it was seeded from. */
87
+ sandbox?: string;
88
+ }
89
+
90
+ export interface StartOptions {
91
+ /** Recorded as the run's actor in the ledger and passed to the policy. */
92
+ startedBy: Principal;
93
+ /**
94
+ * Use this run id instead of a new one, so a retried start makes one run. Starting an id
95
+ * that exists returns it, without starting another, when the workflow, the input (compared
96
+ * as JSON), `startedBy` and `sandbox` are the same, and throws `invalid_input` naming what differs
97
+ * when they are not. Of two starts racing with one new id, the first's run stands.
98
+ */
99
+ runId?: string;
100
+ /**
101
+ * Start a sandbox run, seeded from the config's scenario of this name: it calls its own fakes,
102
+ * made for it by its connectors' makers, instead of the drivers, and does not wait on sleeps.
103
+ * Sandbox runs go on the app's queue like any run, so several run at once.
104
+ */
105
+ sandbox?: string;
106
+ }
107
+
108
+ /** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
109
+ export class SanomaClient {
110
+ private readonly dbos: DBOSClient;
111
+ private readonly config: ResolvedConfig;
112
+ private readonly drivers: ReadonlyMap<string, Driver>;
113
+ /** The config's own ledger store, or a Postgres one made for this client, which `close` then closes. */
114
+ private readonly audit: OwnedLedger;
115
+ /** On the config's database, for the credentials table: opened on first use. */
116
+ private readonly credentialsPool: LazyPool;
117
+
118
+ private constructor(dbos: DBOSClient, config: ResolvedConfig, drivers: ReadonlyMap<string, Driver>) {
119
+ this.dbos = dbos;
120
+ this.config = config;
121
+ this.drivers = drivers;
122
+ this.audit = ownedLedger(config);
123
+ this.credentialsPool = lazyPool(config.databaseUrl, "credentials");
124
+ }
125
+
126
+ /**
127
+ * Connects to the runtime's Postgres for the config's app. Checks the config first and throws
128
+ * what the worker would refuse. Reads ledgers from the config's ledger store, or from the
129
+ * Postgres store on its database when it has none.
130
+ */
131
+ static async connect(config: SanomaConfig): Promise<SanomaClient> {
132
+ const resolved = resolveConfig(config);
133
+ const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
134
+ return new SanomaClient(dbos, resolved, resolved.driversByVendor);
135
+ }
136
+
137
+ /**
138
+ * Checks the input against the workflow's schema, then queues a run for the worker. The run
139
+ * gets the input as sent, not as the schema parsed it: the worker parses it once, so a schema
140
+ * with a `.transform` or a default sees the caller's value, and `run.started` records it.
141
+ * `workflow` is a definition, or the name of one in the config's `workflows`.
142
+ */
143
+ async start<S extends z.ZodType>(
144
+ workflow: WorkflowDefinition<any, S> | string,
145
+ input: z.input<S>,
146
+ options: StartOptions,
147
+ ): Promise<string> {
148
+ if (typeof workflow === "string") {
149
+ const named = this.config.workflows.get(workflow);
150
+ if (!named) {
151
+ const known = [...this.config.workflows.keys()];
152
+ throw new SanomaError("invalid_input", `No workflow named "${workflow}"; the config has ${known.join(", ")}`, {
153
+ workflow,
154
+ workflows: known,
155
+ });
156
+ }
157
+ workflow = named;
158
+ }
159
+ parseOrThrow(workflow.input, input, `The input does not match ${workflow.name}'s schema`);
160
+ const startedBy = parseOrThrow(
161
+ Principal,
162
+ options?.startedBy,
163
+ '`startedBy` must be a principal, such as { id: "alice" }',
164
+ );
165
+ const { runId } = options;
166
+ const sandbox = parseOrThrow(
167
+ z.string().min(1).optional(),
168
+ options.sandbox,
169
+ "`sandbox` must be the name of a scenario in the config's `scenarios/`",
170
+ );
171
+ const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }) };
172
+ // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
173
+ const handle = await this.dbos
174
+ .enqueue(
175
+ {
176
+ queueName: this.config.queueName,
177
+ workflowName: workflow.name,
178
+ workflowID: runId,
179
+ applicationName: this.config.appName,
180
+ authenticatedUser: startedBy.id,
181
+ authenticatedRoles: startedBy.groups ?? [],
182
+ // Kept on the run's status row too, so a listing tells sandbox runs apart without its input.
183
+ ...(sandbox === undefined ? {} : { attributes: { sandbox } }),
184
+ },
185
+ args,
186
+ )
187
+ .catch((err: unknown) => {
188
+ if (runId !== undefined && err instanceof DBOSErrors.DBOSConflictingWorkflowError) {
189
+ throw new SanomaError(
190
+ "invalid_input",
191
+ `Run ${runId} already exists as another workflow than ${workflow.name}; use another run id`,
192
+ { runId, differs: ["workflow"] },
193
+ { cause: err },
194
+ );
195
+ }
196
+ throw err;
197
+ });
198
+ // After the enqueue, not before: of two starts racing with one new id, the first's run stands.
199
+ if (runId !== undefined) await this.mustMatch(runId, args);
200
+ return handle.workflowID;
201
+ }
202
+
203
+ /** The run's audit record, in order, from the ledger store. */
204
+ async ledger(runId: string): Promise<LedgerRecord[]> {
205
+ await this.mustExist(runId);
206
+ return this.audit.store.read(runId);
207
+ }
208
+
209
+ /**
210
+ * The app's runs, newest first: the latest `limit`, or the latest `limit` with a `status`.
211
+ * Only the approvals tell `running` from `waiting`, so those read PENDING runs a page at a
212
+ * time until `limit` have that status.
213
+ */
214
+ async runs({ limit = 20, status, workflow }: RunsFilter = {}): Promise<RunSummary[]> {
215
+ const list = (more: { limit: number; offset?: number }) =>
216
+ this.dbos.listWorkflows({
217
+ ...more,
218
+ status: status && dbosStatusesOf(status),
219
+ workflowName: workflow,
220
+ sortDesc: true,
221
+ applicationName: this.config.appName,
222
+ loadInput: false,
223
+ // The output holds the error a summary shows; only an ended run can have one.
224
+ loadOutput: status === undefined || status === "failed" || status === "cancelled",
225
+ });
226
+ if (status !== "running" && status !== "waiting") {
227
+ return Promise.all((await list({ limit })).map((r) => this.summarize(r)));
228
+ }
229
+ return firstMatching(
230
+ async (page) => Promise.all((await list(page)).map((r) => this.summarize(r))),
231
+ (run) => run.status === status,
232
+ limit,
233
+ Math.max(limit * 2, 50),
234
+ );
235
+ }
236
+
237
+ async run(runId: string): Promise<RunSummary | undefined> {
238
+ const row = await this.dbos.getWorkflow(runId);
239
+ return row && this.summarize(row);
240
+ }
241
+
242
+ async approvals(runId: string): Promise<ApprovalState[]> {
243
+ return (await this.dbos.getEvent<ApprovalState[]>(runId, APPROVALS_EVENT, 0)) ?? [];
244
+ }
245
+
246
+ /**
247
+ * Sends a decision on the run's pending approval (or the one named), as `message.by`, and
248
+ * waits for the run to read it. Returns the approval as decided. Throws without sending when
249
+ * it is decided already (`already_decided`), when the run has finished, failed or been
250
+ * cancelled (`run_ended`), when there is nothing to decide (`no_pending_approval`,
251
+ * `run_not_found`), or when `by` may not decide it (`not_approver`); and throws
252
+ * `already_decided` too when another decision reached the run first, and `not_approver` when
253
+ * the run read this one and refused it.
254
+ *
255
+ * If the run does not read the decision within `timeoutSeconds` (30 by default; the worker
256
+ * may be down), returns the approval as it stands, still `pending`: the decision stays
257
+ * queued for the run, which reads it when it next runs.
258
+ */
259
+ async decide(
260
+ runId: string,
261
+ message: ApprovalMessage,
262
+ approvalId?: string,
263
+ options: { timeoutSeconds?: number } = {},
264
+ ): Promise<ApprovalState> {
265
+ const msg = parseOrThrow(ApprovalMessage, message, "Not a decision");
266
+ // A run that does not exist has no approvals, so run_not_found never hides already_decided.
267
+ const [all, row] = await Promise.all([this.approvals(runId), this.mustExist(runId)]);
268
+ const target = approvalId ? all.find((a) => a.id === approvalId) : all.find((a) => a.status === "pending");
269
+ if (target && target.status !== "pending") throw alreadyDecided(runId, target);
270
+ // A run that has ended reads no more messages: a decision sent to it would wait forever.
271
+ const status = runStatus(row.status, all);
272
+ if (isEnded(status)) {
273
+ throw new SanomaError("run_ended", `Run ${runId} has ${status}; it takes no more decisions`, {
274
+ runId,
275
+ status,
276
+ ...(target ? { approvalId: target.id } : {}),
277
+ });
278
+ }
279
+ if (!target) {
280
+ throw new SanomaError(
281
+ "no_pending_approval",
282
+ `Run ${runId} has no pending approval${approvalId ? ` "${approvalId}"` : ""}`,
283
+ { runId, ...(approvalId ? { approvalId } : {}) },
284
+ );
285
+ }
286
+ if (!mayDecide(target, msg.by)) {
287
+ throw new SanomaError("not_approver", `${notApprover(target, msg.by)} (${target.id})`, {
288
+ runId,
289
+ approvalId: target.id,
290
+ approver: target.approver,
291
+ });
292
+ }
293
+ // The id names this message, so the run's answer says whether it decided with it, and is
294
+ // in its idempotency key, so a retried send queues it once.
295
+ const id = msg.id ?? randomUUID();
296
+ await this.dbos.send(runId, { ...msg, id }, topicOf(target.id), messageKeyOf(target.id, id));
297
+ // DBOSClient does not LISTEN for events: it polls, every 10 s unless told otherwise. A
298
+ // running worker answers within moments, so poll often at first, then once a second.
299
+ const event = decisionEventOf(target.id);
300
+ const timeoutSeconds = options.timeoutSeconds ?? 30;
301
+ const soon = Math.min(2, timeoutSeconds);
302
+ const decided =
303
+ (await this.dbos.getEvent<ApprovalState>(runId, event, { timeoutSeconds: soon, pollingIntervalMs: 100 })) ??
304
+ (timeoutSeconds > soon
305
+ ? await this.dbos.getEvent<ApprovalState>(runId, event, {
306
+ timeoutSeconds: timeoutSeconds - soon,
307
+ pollingIntervalMs: 1000,
308
+ })
309
+ : null);
310
+ const now = decided ?? (await this.approvals(runId)).find((a) => a.id === target.id) ?? target;
311
+ // Read, but refused: the run checks the sender again, and said no.
312
+ const refused = now.refused.find((r) => r.id === id);
313
+ if (refused) {
314
+ throw new SanomaError("not_approver", `${refused.reason} (${target.id})`, {
315
+ runId,
316
+ approvalId: target.id,
317
+ approver: target.approver,
318
+ });
319
+ }
320
+ if (now.status !== "pending" && now.decidedWith !== id) throw alreadyDecided(runId, now);
321
+ return now;
322
+ }
323
+
324
+ /**
325
+ * The run's output once it ends, or its error. Throws `run_running` when it has not ended
326
+ * within `timeoutMs`; the run goes on.
327
+ */
328
+ async result(runId: string, timeoutMs = 30_000): Promise<unknown> {
329
+ await this.mustExist(runId);
330
+ return this.awaitResult(runId, timeoutMs);
331
+ }
332
+
333
+ /**
334
+ * Starts the built-in drift workflow, which reads the config's data files itself: `start`
335
+ * with its input, `{}`. Returns the run id; `driftReport` waits for its report. Throws
336
+ * `invalid_input` when the config's connectors declare no resource types, so it has none.
337
+ */
338
+ async drift(options: StartOptions): Promise<string> {
339
+ return this.start(this.builtIn(DRIFT_WORKFLOW), {}, options);
340
+ }
341
+
342
+ /** The report of a drift run once it ends: as `applyReport` for apply. */
343
+ async driftReport(runId: string): Promise<DriftReport> {
344
+ return (await this.reportOf(runId, DRIFT_WORKFLOW)) as DriftReport;
345
+ }
346
+
347
+ /**
348
+ * Starts the built-in apply workflow, which reads the config's data files itself, with `input`
349
+ * (`only`, `bind`, `answers`; `{}` for all). Returns the run id; `applyReport` waits for its
350
+ * report. Throws `invalid_input` when the config's connectors declare no resource types.
351
+ */
352
+ async apply(input: ApplyInput, options: StartOptions): Promise<string> {
353
+ return this.start(this.builtIn(APPLY_WORKFLOW), input, options);
354
+ }
355
+
356
+ /**
357
+ * The report of an apply run once it ends. Throws `run_not_found`, `invalid_input` for another
358
+ * workflow's run, the run's error when it failed, and `run_running` when it has not ended
359
+ * within 30 s.
360
+ */
361
+ async applyReport(runId: string): Promise<ApplyReport> {
362
+ return (await this.reportOf(runId, APPLY_WORKFLOW)) as ApplyReport;
363
+ }
364
+
365
+ /** The built-in workflow `name`; `invalid_input` when the config has none, as its connectors declare no resource types. */
366
+ private builtIn(name: string) {
367
+ const workflow = this.config.workflows.get(name);
368
+ if (!workflow) {
369
+ throw new SanomaError(
370
+ "invalid_input",
371
+ `The config's connectors declare no resource types, so it has no built-in ${name} workflow`,
372
+ );
373
+ }
374
+ return workflow;
375
+ }
376
+
377
+ /** The output of a run of the built-in `workflow` once it ends. */
378
+ private async reportOf(runId: string, workflow: string): Promise<unknown> {
379
+ const row = await this.mustExist(runId, { loadOutput: true });
380
+ if (row.workflowName !== workflow) {
381
+ throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${workflow}`, {
382
+ runId,
383
+ workflow: row.workflowName,
384
+ });
385
+ }
386
+ // A finished run's report came with its row; else wait for the run.
387
+ return row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000);
388
+ }
389
+
390
+ /**
391
+ * Stores a value for a variable a driver's `env` declares, as `by`, and tells the workers: each
392
+ * loads it into its environment, unless its environment sets the variable itself. Refuses
393
+ * (`invalid_input`) a name no driver declares, and a value the variable's schema refuses, with
394
+ * the schema's reason and never the value; an empty value too (`clearCredential` unsets one).
395
+ * Nothing records the value but its row, which records `by` and when.
396
+ */
397
+ async setCredential(name: string, value: string, options: { by: string }): Promise<void> {
398
+ // Checked against every declaration of the name, as the worker checks it.
399
+ const statuses = this.declared(name, (n) => (n === name ? value : undefined));
400
+ const setBy = actorOf(options);
401
+ const refused = statuses.find((c) => c.status !== "set");
402
+ if (refused) {
403
+ const problem = refused.problem ?? "it is empty; clear it to unset it";
404
+ throw new SanomaError("invalid_input", `${name} cannot be set: ${problem}`, {
405
+ name,
406
+ issues: [{ path: ["value"], message: problem, code: "custom" }],
407
+ });
408
+ }
409
+ await storeCredential(await this.credentialsDb(), { name, value, setBy });
410
+ }
411
+
412
+ /**
413
+ * Deletes the stored value of a declared variable, and tells the workers, which unset it unless
414
+ * their environment sets it. `by` is who asks; nothing records it once the row is gone.
415
+ * Refuses (`invalid_input`) a name no driver declares.
416
+ */
417
+ async clearCredential(name: string, options: { by: string }): Promise<void> {
418
+ this.declared(name, () => undefined);
419
+ actorOf(options);
420
+ await deleteCredential(await this.credentialsDb(), name);
421
+ }
422
+
423
+ /**
424
+ * Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
425
+ * a driver or not, such as a data file's `secret("NAME")` or the app's own (its GitHub App's
426
+ * keys). A name a driver declares is checked as `setCredential` checks it; any other name must
427
+ * look like a variable's, and the value must not be empty (`invalid_input`, never the value).
428
+ */
429
+ async setSecret(name: string, value: string, options: { by: string }): Promise<void> {
430
+ if ([...credentialsOf(this.drivers, () => undefined).values()].flat().some((c) => c.name === name)) {
431
+ return this.setCredential(name, value, options);
432
+ }
433
+ const setBy = actorOf(options);
434
+ const problem = !VARIABLE.test(name) ? "That is not a variable name" : !value ? "Enter a value" : undefined;
435
+ if (problem) {
436
+ const path = VARIABLE.test(name) ? "value" : "name";
437
+ throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
438
+ }
439
+ await storeCredential(await this.credentialsDb(), { name, value, setBy });
440
+ }
441
+
442
+ /**
443
+ * The values of these secrets, as a driver's `secrets.get` reads them: this process's
444
+ * environment, which wins, else the stored value, with who set it and when. A name with neither
445
+ * is left out. For server code: a page is told whether one is set, never its value.
446
+ */
447
+ async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
448
+ const rows = await storedCredentials(await this.credentialsDb(), names);
449
+ const found = new Map<string, StoredSecret>();
450
+ for (const name of names) {
451
+ const env = process.env[name];
452
+ const row = rows.get(name);
453
+ if (env && env !== row?.value) found.set(name, { value: env, source: "environment" });
454
+ else if (row) found.set(name, { ...row, source: "stored" });
455
+ }
456
+ return found;
457
+ }
458
+
459
+ /**
460
+ * Each vendor's declared variables and their statuses now: from this process's environment,
461
+ * which wins, else from the stored credentials, with `source` saying which (a value equal to
462
+ * the stored one, as a worker in this process loads it, is `stored`), and who set a stored one
463
+ * and when. Never a value.
464
+ */
465
+ async credentials(): Promise<Map<string, CredentialStatus[]>> {
466
+ const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
467
+ if (!names.length) return new Map();
468
+ const rows = await storedCredentials(await this.credentialsDb(), names);
469
+ const statuses = credentialsOf(this.drivers, (name) => process.env[name] || rows.get(name)?.value);
470
+ const sourced = (c: CredentialStatus): CredentialStatus => {
471
+ const row = rows.get(c.name);
472
+ const env = process.env[c.name];
473
+ // A worker in this process loads the stored values into its environment: those are stored.
474
+ if (env && env !== row?.value) return { ...c, source: "environment" };
475
+ return row ? { ...c, source: "stored", setBy: row.setBy, setAt: row.setAt } : c;
476
+ };
477
+ return new Map([...statuses].map(([vendor, list]) => [vendor, list.map(sourced)]));
478
+ }
479
+
480
+ async close() {
481
+ await Promise.all([this.dbos.destroy(), this.credentialsPool.close(), this.audit.close()]);
482
+ }
483
+
484
+ /**
485
+ * The name's statuses with `lookup`'s value, one per vendor whose drivers declare it, or
486
+ * `invalid_input` when none does.
487
+ */
488
+ private declared(name: string, lookup: (name: string) => string | undefined): CredentialStatus[] {
489
+ const all = [...credentialsOf(this.drivers, lookup).values()].flat();
490
+ const statuses = all.filter((c) => c.name === name);
491
+ if (statuses.length) return statuses;
492
+ const known = [...new Set(all.map((c) => c.name))];
493
+ // A name that is no variable's may be a value sent in its place: it is not repeated.
494
+ const named = VARIABLE.test(name);
495
+ const message = `${named ? `No driver declares ${name}` : "That is not a variable name"}; ${
496
+ known.length ? `the declared variables are ${known.join(", ")}` : "none declares any"
497
+ }`;
498
+ throw new SanomaError("invalid_input", message, {
499
+ ...(named && { name }),
500
+ issues: [{ path: ["name"], message, code: "custom" }],
501
+ });
502
+ }
503
+
504
+ /** The pool the credentials are read and written with, on the database once it is prepared. */
505
+ private credentialsDb(): Promise<Pool> {
506
+ return this.credentialsPool.db();
507
+ }
508
+
509
+ /** Refuses a run id whose run, as stored, was started with another input, by someone else, or in another sandbox. */
510
+ private async mustMatch(runId: string, args: RunArgs) {
511
+ const [stored] = ((await this.dbos.getWorkflow(runId))?.input ?? []) as [RunArgs?];
512
+ const differs = (["input", "startedBy", "sandbox"] as const).filter(
513
+ (key) => !stored || !sameJson(stored[key], args[key]),
514
+ );
515
+ if (differs.length) {
516
+ throw new SanomaError(
517
+ "invalid_input",
518
+ `Run ${runId} already exists with a different ${differs.join(", ")}; use another run id`,
519
+ { runId, differs },
520
+ );
521
+ }
522
+ }
523
+
524
+ /** The run's status row, read without its input (and its output, unless asked), or `run_not_found`. */
525
+ private async mustExist(runId: string, { loadOutput = false } = {}) {
526
+ const [row] = await this.dbos.listWorkflows({ workflowIDs: [runId], loadInput: false, loadOutput });
527
+ if (!row) throw new SanomaError("run_not_found", `No run ${runId}`, { runId });
528
+ return row;
529
+ }
530
+
531
+ /** The run's output once it ends, or its error, polled every 100 ms; `run_running` after `timeoutMs`. */
532
+ private async awaitResult(runId: string, timeoutMs: number): Promise<unknown> {
533
+ const running = () =>
534
+ new SanomaError("run_running", `Run ${runId} is still running after ${timeoutMs} ms`, { runId, timeoutMs });
535
+ let timer: NodeJS.Timeout | undefined;
536
+ try {
537
+ // DBOSClient's wait takes no timeout of its own.
538
+ return await Promise.race([
539
+ this.dbos.retrieveWorkflow(runId).getResult({ pollingIntervalMs: 100 }),
540
+ new Promise((_, reject) => {
541
+ timer = setTimeout(() => reject(running()), timeoutMs).unref();
542
+ }),
543
+ ]);
544
+ } finally {
545
+ clearTimeout(timer);
546
+ }
547
+ }
548
+
549
+ private async summarize(r: {
550
+ workflowID: string;
551
+ workflowName: string;
552
+ status: string;
553
+ createdAt: number;
554
+ updatedAt?: number;
555
+ authenticatedUser?: string;
556
+ authenticatedRoles?: string[];
557
+ error?: unknown;
558
+ attributes?: Record<string, unknown>;
559
+ }): Promise<RunSummary> {
560
+ const approvals = await this.approvals(r.workflowID);
561
+ const groups = r.authenticatedRoles ?? [];
562
+ return {
563
+ runId: r.workflowID,
564
+ workflow: r.workflowName,
565
+ status: runStatus(r.status, approvals),
566
+ startedBy: r.authenticatedUser ? { id: r.authenticatedUser, ...(groups.length ? { groups } : {}) } : undefined,
567
+ createdAt: r.createdAt,
568
+ updatedAt: r.updatedAt,
569
+ approvals,
570
+ error: r.error ? String((r.error as Error).message ?? r.error) : undefined,
571
+ ...(typeof r.attributes?.sandbox === "string" && { sandbox: r.attributes.sandbox }),
572
+ };
573
+ }
574
+ }
575
+
576
+ /** The value as JSON reads it back: a Date as its string, undefined members gone. */
577
+ const asJson = (v: unknown): unknown => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
578
+
579
+ /** True when the two are the same JSON, whatever the order of their keys. */
580
+ const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a), asJson(b));
581
+
582
+ /** A secret's value as `SanomaClient.secrets` reads it, and where from. */
583
+ export interface StoredSecret {
584
+ value: string;
585
+ source: "environment" | "stored";
586
+ /** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
587
+ setBy?: string;
588
+ setAt?: string;
589
+ }
590
+
591
+ /** What an environment variable's name looks like. */
592
+ const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
593
+
594
+ /** `options.by`, who sets or clears a credential: a name, or `invalid_input`. */
595
+ const actorOf = (options: { by: string }): string =>
596
+ parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
597
+
598
+ const alreadyDecided = (runId: string, a: ApprovalState) =>
599
+ new SanomaError("already_decided", `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
600
+ runId,
601
+ approvalId: a.id,
602
+ status: a.status,
603
+ decidedBy: a.decidedBy,
604
+ });