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