@sanoma-ai/workflows 0.1.0 → 0.4.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/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +35 -2
- package/dist/call.js +123 -43
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +123 -9
- package/dist/client.js +355 -34
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +109 -28
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +38 -3
- package/dist/datafile.js +166 -13
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
package/src/client.ts
CHANGED
|
@@ -4,17 +4,50 @@ import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from
|
|
|
4
4
|
import type { Pool } from "pg";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { APPROVALS_EVENT, ApprovalMessage, decisionEventOf, messageKeyOf, topicOf } from "./approvals.ts";
|
|
7
|
+
import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.ts";
|
|
8
|
+
import { collectionsDriver } from "./collection.ts";
|
|
7
9
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
8
|
-
import { deleteCredential, storeCredential, storedCredentials } from "./credentials.ts";
|
|
10
|
+
import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.ts";
|
|
9
11
|
import { dbosClient, lazyPool, type LazyPool } from "./db.ts";
|
|
10
12
|
import { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
11
|
-
import { type ApprovalState,
|
|
13
|
+
import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
|
|
14
|
+
import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
|
|
12
15
|
import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
import { isDirect } from "./direct.ts";
|
|
17
|
+
import {
|
|
18
|
+
ApprovalPendingError,
|
|
19
|
+
type ErrorInfo,
|
|
20
|
+
errorCode,
|
|
21
|
+
errorInfo,
|
|
22
|
+
parseOrThrow,
|
|
23
|
+
PolicyDeniedError,
|
|
24
|
+
SanomaError,
|
|
25
|
+
} from "./errors.ts";
|
|
26
|
+
import { insertRecord, type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
|
|
27
|
+
import type { CallContext, Driver, Op } from "./op.ts";
|
|
28
|
+
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
29
|
+
import { admit, readPeople } from "./resources.ts";
|
|
30
|
+
import type { RunArgs, StartHold } from "./run.ts";
|
|
31
|
+
import type { Secrets } from "./secret.ts";
|
|
32
|
+
import {
|
|
33
|
+
BUILT_IN_WORKFLOWS,
|
|
34
|
+
CALL_WORKFLOW,
|
|
35
|
+
DATA_VENDOR,
|
|
36
|
+
errorMessage,
|
|
37
|
+
isEnded,
|
|
38
|
+
refusalOf,
|
|
39
|
+
type RunStatus,
|
|
40
|
+
WORKFLOW_ACTION,
|
|
41
|
+
} from "./shared.ts";
|
|
42
|
+
|
|
43
|
+
/** A principal, or its id alone. */
|
|
44
|
+
const PrincipalOrId = z.union([
|
|
45
|
+
z
|
|
46
|
+
.string()
|
|
47
|
+
.regex(/\S/)
|
|
48
|
+
.transform((id) => ({ id })),
|
|
49
|
+
Principal,
|
|
50
|
+
]);
|
|
18
51
|
|
|
19
52
|
/**
|
|
20
53
|
* The run status each DBOS status maps to. `waiting` is PENDING with an approval pending, so
|
|
@@ -76,7 +109,7 @@ export interface RunSummary {
|
|
|
76
109
|
runId: string;
|
|
77
110
|
workflow: string;
|
|
78
111
|
status: RunStatus;
|
|
79
|
-
/** Who started the run, as
|
|
112
|
+
/** Who started the run, as `start` admitted them. */
|
|
80
113
|
startedBy?: Principal;
|
|
81
114
|
createdAt: number;
|
|
82
115
|
updatedAt?: number;
|
|
@@ -87,12 +120,17 @@ export interface RunSummary {
|
|
|
87
120
|
}
|
|
88
121
|
|
|
89
122
|
export interface StartOptions {
|
|
90
|
-
/**
|
|
91
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Who starts it, by id (`"ada"`, or `{ id: "ada" }`): recorded as the run's actor in the ledger
|
|
125
|
+
* and passed to the policy. When the data files declare people, the run is started as the
|
|
126
|
+
* declared principal (`principalOf`: their groups, not any given here), and an id nobody
|
|
127
|
+
* declares is refused with `not_admitted`.
|
|
128
|
+
*/
|
|
129
|
+
startedBy: Principal | string;
|
|
92
130
|
/**
|
|
93
131
|
* Use this run id instead of a new one, so a retried start makes one run. Starting an id
|
|
94
132
|
* 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
|
|
133
|
+
* as JSON), `startedBy`'s id and `sandbox` are the same, and throws `invalid_input` naming what differs
|
|
96
134
|
* when they are not. Of two starts racing with one new id, the first's run stands.
|
|
97
135
|
*/
|
|
98
136
|
runId?: string;
|
|
@@ -104,6 +142,20 @@ export interface StartOptions {
|
|
|
104
142
|
sandbox?: string;
|
|
105
143
|
}
|
|
106
144
|
|
|
145
|
+
/** What `SanomaClient.call` takes besides the operation's id and its input. */
|
|
146
|
+
export interface CallOptions {
|
|
147
|
+
/**
|
|
148
|
+
* Who calls, by id, admitted as `startedBy` is: the policy decides as them, a write's record
|
|
149
|
+
* names them, and a save's `updatedBy` is their id.
|
|
150
|
+
*/
|
|
151
|
+
actor: Principal | string;
|
|
152
|
+
/**
|
|
153
|
+
* The call's id, so a retried write is made once: the same id with the same input returns what
|
|
154
|
+
* the first call returned, with another input is refused (`invalid_input`). A new one by default.
|
|
155
|
+
*/
|
|
156
|
+
id?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
107
159
|
/** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
|
|
108
160
|
export class SanomaClient {
|
|
109
161
|
private readonly dbos: DBOSClient;
|
|
@@ -113,6 +165,8 @@ export class SanomaClient {
|
|
|
113
165
|
private readonly audit: OwnedLedger;
|
|
114
166
|
/** On the config's database, for the credentials table: opened on first use. */
|
|
115
167
|
private readonly credentialsPool: LazyPool;
|
|
168
|
+
/** What a direct call's driver reads secrets from, as a worker's live driver does. */
|
|
169
|
+
private readonly storedSecrets: Secrets;
|
|
116
170
|
|
|
117
171
|
private constructor(dbos: DBOSClient, config: ResolvedConfig, drivers: ReadonlyMap<string, Driver>) {
|
|
118
172
|
this.dbos = dbos;
|
|
@@ -120,6 +174,7 @@ export class SanomaClient {
|
|
|
120
174
|
this.drivers = drivers;
|
|
121
175
|
this.audit = ownedLedger(config);
|
|
122
176
|
this.credentialsPool = lazyPool(config.databaseUrl, "credentials");
|
|
177
|
+
this.storedSecrets = storedSecrets(() => this.credentialsDb(), "sanoma");
|
|
123
178
|
}
|
|
124
179
|
|
|
125
180
|
/**
|
|
@@ -144,6 +199,13 @@ export class SanomaClient {
|
|
|
144
199
|
input: z.input<S>,
|
|
145
200
|
options: StartOptions,
|
|
146
201
|
): Promise<string> {
|
|
202
|
+
const startedBy = await this.admitted(
|
|
203
|
+
parseOrThrow(
|
|
204
|
+
PrincipalOrId,
|
|
205
|
+
options?.startedBy,
|
|
206
|
+
'`startedBy` must be a principal, such as "alice" or { id: "alice" }',
|
|
207
|
+
),
|
|
208
|
+
);
|
|
147
209
|
if (typeof workflow === "string") {
|
|
148
210
|
const named = this.config.workflows.get(workflow);
|
|
149
211
|
if (!named) {
|
|
@@ -155,19 +217,17 @@ export class SanomaClient {
|
|
|
155
217
|
}
|
|
156
218
|
workflow = named;
|
|
157
219
|
}
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
Principal,
|
|
161
|
-
options?.startedBy,
|
|
162
|
-
'`startedBy` must be a principal, such as { id: "alice" }',
|
|
163
|
-
);
|
|
220
|
+
// The policy decides the start on the input as parsed, its defaults filled, as the Cedar schema types it.
|
|
221
|
+
const parsed = parseOrThrow(workflow.input, input, `The input does not match ${workflow.name}'s schema`);
|
|
164
222
|
const { runId } = options;
|
|
165
223
|
const sandbox = parseOrThrow(
|
|
166
224
|
z.string().min(1).optional(),
|
|
167
225
|
options.sandbox,
|
|
168
226
|
"`sandbox` must be the name of a scenario in the config's `scenarios/`",
|
|
169
227
|
);
|
|
170
|
-
const
|
|
228
|
+
const hold = await this.startHold(workflow.name, parsed, startedBy, runId);
|
|
229
|
+
const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
|
|
230
|
+
const { onBehalfOf } = startedBy;
|
|
171
231
|
// An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
|
|
172
232
|
const handle = await this.dbos
|
|
173
233
|
.enqueue(
|
|
@@ -178,8 +238,9 @@ export class SanomaClient {
|
|
|
178
238
|
applicationName: this.config.appName,
|
|
179
239
|
authenticatedUser: startedBy.id,
|
|
180
240
|
authenticatedRoles: startedBy.groups ?? [],
|
|
181
|
-
// Kept on the run's status row too, so a listing tells sandbox runs apart without its input
|
|
182
|
-
|
|
241
|
+
// Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
|
|
242
|
+
// and says whom an agent's run is for (DBOS keeps the principal's id and groups itself).
|
|
243
|
+
...((sandbox !== undefined || onBehalfOf !== undefined) && { attributes: { sandbox, onBehalfOf } }),
|
|
183
244
|
},
|
|
184
245
|
args,
|
|
185
246
|
)
|
|
@@ -199,6 +260,221 @@ export class SanomaClient {
|
|
|
199
260
|
return handle.workflowID;
|
|
200
261
|
}
|
|
201
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The policy's answer to starting a run of `workflow` as `startedBy` (`decideStart`), for a
|
|
265
|
+
* workflow of the config's own: a deny throws `policy_denied`; a hold is what the run waits on
|
|
266
|
+
* before its body. A run `runId` names already is returned as it is, whatever the policy says now.
|
|
267
|
+
*/
|
|
268
|
+
private async startHold(
|
|
269
|
+
workflow: string,
|
|
270
|
+
input: unknown,
|
|
271
|
+
startedBy: Principal,
|
|
272
|
+
runId: string | undefined,
|
|
273
|
+
): Promise<StartHold | undefined> {
|
|
274
|
+
const { policy } = this.config;
|
|
275
|
+
if (BUILT_IN_WORKFLOWS.has(workflow) || policy === allowAll) return undefined;
|
|
276
|
+
if (runId !== undefined && (await this.row(runId))) return undefined;
|
|
277
|
+
const decision = decideStart(policy, workflow, input, startedBy);
|
|
278
|
+
if (decision.kind === "allow") return undefined;
|
|
279
|
+
if (decision.kind === "deny") throw new PolicyDeniedError(`${WORKFLOW_ACTION}${workflow}`, decision.reason);
|
|
280
|
+
return startHoldOf(workflow, decision);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Calls an operation directly, outside a run, as `actor`: one that may be (`isDirect`), a
|
|
285
|
+
* collection's or a vendor's read, never a vendor's write, the runtime's own or a resource
|
|
286
|
+
* type's (`invalid_input`, as is an unknown id or input its schema refuses). The policy decides
|
|
287
|
+
* it as the person, in a run `direct:<actor>:<id>` of the workflow `call` with no approvals. A
|
|
288
|
+
* deny throws `policy_denied` and writes nothing. An allowed call runs the driver once, without
|
|
289
|
+
* DBOS and without a retry (a driver's failure is `driver_failed`). A read returns its output
|
|
290
|
+
* and records nothing. A write is recorded as one `op.called` (seq 0) in that run, and with the
|
|
291
|
+
* default ledger in one transaction with the record it saves, so both are written or neither;
|
|
292
|
+
* a write with the same id and input returns the output recorded. When the policy asks for an
|
|
293
|
+
* approval, the call becomes run `call:<actor>:<id>` of the built-in `call` workflow, started
|
|
294
|
+
* as the person, which makes it once approved, and this throws `approval_pending` with its id.
|
|
295
|
+
*/
|
|
296
|
+
async call(opId: string, input: unknown, options: CallOptions): Promise<unknown> {
|
|
297
|
+
const actor = await this.admitted(
|
|
298
|
+
parseOrThrow(PrincipalOrId, options?.actor, '`actor` must be a principal, such as "alice" or { id: "alice" }'),
|
|
299
|
+
);
|
|
300
|
+
const id =
|
|
301
|
+
parseOrThrow(z.string().min(1).optional(), options.id, "`id` must be a non-empty string") ?? randomUUID();
|
|
302
|
+
const { ops, types, drivers, policy } = this.config;
|
|
303
|
+
const op = ops.get(opId);
|
|
304
|
+
if (!op || !isDirect(op, types)) {
|
|
305
|
+
const why = op ? "it is a workflow's to call: start one that uses it" : "no connector or collection declares it";
|
|
306
|
+
throw new SanomaError("invalid_input", `${opId} cannot be called directly: ${why}`, { op: opId });
|
|
307
|
+
}
|
|
308
|
+
const fn = drivers.get(op.id);
|
|
309
|
+
if (!fn) throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
|
|
310
|
+
const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
|
|
311
|
+
const runId = `direct:${actor.id}:${id}`;
|
|
312
|
+
const heldId = `call:${actor.id}:${id}`;
|
|
313
|
+
const held: RunArgs = { input: { op: op.id, input }, startedBy: actor };
|
|
314
|
+
const logged = recorded(op, parsed);
|
|
315
|
+
const write = op.effect !== "read";
|
|
316
|
+
// Only an id the caller gave can have been used: a write made, or held, with it settles it.
|
|
317
|
+
if (write && options.id !== undefined) {
|
|
318
|
+
const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
|
|
319
|
+
if (first) return first.output;
|
|
320
|
+
}
|
|
321
|
+
const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) =>
|
|
322
|
+
drivers.get(get)?.(
|
|
323
|
+
{ key },
|
|
324
|
+
{ idempotencyKey: `${runId}:before`, runId, opId: get, attempt: 1, secrets: this.storedSecrets, by: actor.id },
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
|
|
328
|
+
const decision = decideCall(policy, op, parsed, actor, run, before);
|
|
329
|
+
if (decision.kind === "deny") throw new PolicyDeniedError(op.id, decision.reason);
|
|
330
|
+
if (decision.kind === "approve") {
|
|
331
|
+
await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
|
|
332
|
+
// A read held before, whose run has ended since, answers with that run's result.
|
|
333
|
+
return (await this.heldCall(op.id, heldId, held))!.output;
|
|
334
|
+
}
|
|
335
|
+
const context: CallContext = {
|
|
336
|
+
idempotencyKey: runId,
|
|
337
|
+
runId,
|
|
338
|
+
opId: op.id,
|
|
339
|
+
attempt: 1,
|
|
340
|
+
secrets: this.storedSecrets,
|
|
341
|
+
by: actor.id,
|
|
342
|
+
};
|
|
343
|
+
if (!write) return outputOf(op, () => fn(parsed, context));
|
|
344
|
+
return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A direct write, made once and recorded once: with the default ledger, a collection's write
|
|
349
|
+
* and its record in one transaction on the database both are in, so both are kept or neither;
|
|
350
|
+
* otherwise the driver's call, then the record in the config's ledger. The first record of the
|
|
351
|
+
* call's id stands: a write that finds one already there (two calls with one id at once) is
|
|
352
|
+
* undone, and answers as `recordedCall` does.
|
|
353
|
+
*/
|
|
354
|
+
private async directWrite(call: {
|
|
355
|
+
op: Op;
|
|
356
|
+
fn: (input: unknown, context: CallContext) => Promise<unknown>;
|
|
357
|
+
parsed: unknown;
|
|
358
|
+
logged: unknown;
|
|
359
|
+
actor: Principal;
|
|
360
|
+
decision: RecordedDecision;
|
|
361
|
+
context: CallContext;
|
|
362
|
+
}): Promise<unknown> {
|
|
363
|
+
const { op, parsed, logged, actor, decision, context } = call;
|
|
364
|
+
const { runId } = context;
|
|
365
|
+
const { dataStore, collections, ledger } = this.config;
|
|
366
|
+
const conn = op.vendor === DATA_VENDOR && !ledger && dataStore ? await (await dataStore.db()).connect() : undefined;
|
|
367
|
+
let broken: Error | undefined;
|
|
368
|
+
try {
|
|
369
|
+
await conn?.query("BEGIN");
|
|
370
|
+
// On the transaction's connection, the collection's own driver: it has no `env` to guard.
|
|
371
|
+
const fn = conn
|
|
372
|
+
? collectionsDriver([collections.get(op.resource)!], { db: async () => conn }).ops[`${op.resource}.${op.name}`]!
|
|
373
|
+
: call.fn;
|
|
374
|
+
const at = Date.now();
|
|
375
|
+
const record = (outcome: { output: unknown } | { error: ErrorInfo }): LedgerRecord => ({
|
|
376
|
+
v: 1,
|
|
377
|
+
app: this.config.appName,
|
|
378
|
+
id: `${runId}:op.called:0`,
|
|
379
|
+
runId,
|
|
380
|
+
seq: 0,
|
|
381
|
+
at,
|
|
382
|
+
actor,
|
|
383
|
+
workflow: CALL_WORKFLOW,
|
|
384
|
+
type: "op.called",
|
|
385
|
+
op: op.id,
|
|
386
|
+
effect: op.effect,
|
|
387
|
+
input: logged,
|
|
388
|
+
decision,
|
|
389
|
+
...outcome,
|
|
390
|
+
durationMs: Date.now() - at,
|
|
391
|
+
});
|
|
392
|
+
let output: unknown;
|
|
393
|
+
try {
|
|
394
|
+
output = await outputOf(op, () => fn(parsed, context));
|
|
395
|
+
} catch (err) {
|
|
396
|
+
// Recorded on the store's own connection; the transaction is rolled back below.
|
|
397
|
+
await this.audit.store.append(record({ error: errorInfo(err) }));
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
const done = record({ output: recorded(op, output) });
|
|
401
|
+
if (!conn) {
|
|
402
|
+
await this.audit.store.append(done);
|
|
403
|
+
return output;
|
|
404
|
+
}
|
|
405
|
+
if (await insertRecord(conn, done)) {
|
|
406
|
+
await conn.query("COMMIT");
|
|
407
|
+
return output;
|
|
408
|
+
}
|
|
409
|
+
await conn.query("ROLLBACK");
|
|
410
|
+
return (await this.recordedCall(runId, op.id, logged))!.output;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
await conn?.query("ROLLBACK").catch((e: Error) => (broken = e));
|
|
413
|
+
throw err;
|
|
414
|
+
} finally {
|
|
415
|
+
conn?.release(broken);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
|
|
421
|
+
* when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
|
|
422
|
+
*/
|
|
423
|
+
private async recordedCall(runId: string, opId: string, input: unknown): Promise<{ output: unknown } | undefined> {
|
|
424
|
+
const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
|
|
425
|
+
if (!first) return undefined;
|
|
426
|
+
const differs = [...(first.op === opId ? [] : ["op"]), ...(sameJson(first.input, input) ? [] : ["input"])];
|
|
427
|
+
if (differs.length) {
|
|
428
|
+
throw new SanomaError(
|
|
429
|
+
"invalid_input",
|
|
430
|
+
`Call ${runId} was made already with a different ${differs.join(" and ")}; use another id`,
|
|
431
|
+
{ runId, differs },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (first.error) {
|
|
435
|
+
throw new SanomaError(
|
|
436
|
+
"driver_failed",
|
|
437
|
+
`${opId} failed when first called as ${runId}: ${first.error.message}; call again with another id`,
|
|
438
|
+
{
|
|
439
|
+
runId,
|
|
440
|
+
op: opId,
|
|
441
|
+
error: first.error,
|
|
442
|
+
},
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return { output: first.output };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* What the held call's run `heldId` returned once it ended (its error, when it failed);
|
|
450
|
+
* `approval_pending` while it has not; undefined when there is no such run. Refused
|
|
451
|
+
* (`invalid_input`) when the run was started with another input or by someone else.
|
|
452
|
+
*/
|
|
453
|
+
private async heldCall(opId: string, heldId: string, args: RunArgs): Promise<{ output: unknown } | undefined> {
|
|
454
|
+
const [row] = await this.dbos.listWorkflows({ workflowIDs: [heldId], loadInput: false, loadOutput: false });
|
|
455
|
+
if (!row) return undefined;
|
|
456
|
+
await this.mustMatch(heldId, args);
|
|
457
|
+
if (!isEnded(runStatus(row.status, []))) throw new ApprovalPendingError(opId, heldId);
|
|
458
|
+
return { output: await this.awaitResult(heldId, 30_000) };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The newest finished run of the workflow, with its output and when it finished; null when it
|
|
463
|
+
* has none. One query, whatever the app's runs.
|
|
464
|
+
*/
|
|
465
|
+
async latest(workflow: string): Promise<{ runId: string; output: unknown; at: number } | null> {
|
|
466
|
+
const [row] = await this.dbos.listWorkflows({
|
|
467
|
+
workflowName: workflow,
|
|
468
|
+
applicationName: this.config.appName,
|
|
469
|
+
status: "SUCCESS",
|
|
470
|
+
sortDesc: true,
|
|
471
|
+
limit: 1,
|
|
472
|
+
loadInput: false,
|
|
473
|
+
loadOutput: true,
|
|
474
|
+
});
|
|
475
|
+
return row ? { runId: row.workflowID, output: row.output, at: row.updatedAt ?? row.createdAt } : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
202
478
|
/** The run's audit record, in order, from the ledger store. */
|
|
203
479
|
async ledger(runId: string): Promise<LedgerRecord[]> {
|
|
204
480
|
await this.mustExist(runId);
|
|
@@ -261,7 +537,9 @@ export class SanomaClient {
|
|
|
261
537
|
approvalId?: string,
|
|
262
538
|
options: { timeoutSeconds?: number } = {},
|
|
263
539
|
): Promise<ApprovalState> {
|
|
264
|
-
const
|
|
540
|
+
const given = parseOrThrow(ApprovalMessage, message, "Not a decision");
|
|
541
|
+
const declared = readPeople(this.config.root);
|
|
542
|
+
const msg = { ...given, by: admit(given.by, declared) };
|
|
265
543
|
// A run that does not exist has no approvals, so run_not_found never hides already_decided.
|
|
266
544
|
const [all, row] = await Promise.all([this.approvals(runId), this.mustExist(runId)]);
|
|
267
545
|
const target = approvalId ? all.find((a) => a.id === approvalId) : all.find((a) => a.status === "pending");
|
|
@@ -282,11 +560,18 @@ export class SanomaClient {
|
|
|
282
560
|
{ runId, ...(approvalId ? { approvalId } : {}) },
|
|
283
561
|
);
|
|
284
562
|
}
|
|
285
|
-
|
|
286
|
-
|
|
563
|
+
const reason = refusalOf(
|
|
564
|
+
target,
|
|
565
|
+
msg.by,
|
|
566
|
+
{ actor: row.authenticatedUser ? { id: row.authenticatedUser } : undefined },
|
|
567
|
+
declared.people,
|
|
568
|
+
);
|
|
569
|
+
if (reason !== undefined) {
|
|
570
|
+
throw new SanomaError("not_approver", `${reason} (${target.id})`, {
|
|
287
571
|
runId,
|
|
288
572
|
approvalId: target.id,
|
|
289
573
|
approver: target.approver,
|
|
574
|
+
reason,
|
|
290
575
|
});
|
|
291
576
|
}
|
|
292
577
|
// The id names this message, so the run's answer says whether it decided with it, and is
|
|
@@ -314,6 +599,7 @@ export class SanomaClient {
|
|
|
314
599
|
runId,
|
|
315
600
|
approvalId: target.id,
|
|
316
601
|
approver: target.approver,
|
|
602
|
+
reason: refused.reason,
|
|
317
603
|
});
|
|
318
604
|
}
|
|
319
605
|
if (now.status !== "pending" && now.decidedWith !== id) throw alreadyDecided(runId, now);
|
|
@@ -335,31 +621,55 @@ export class SanomaClient {
|
|
|
335
621
|
* `invalid_input` when the config's connectors declare no resource types, so it has none.
|
|
336
622
|
*/
|
|
337
623
|
async drift(options: StartOptions): Promise<string> {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
624
|
+
return this.start(this.builtIn(DRIFT_WORKFLOW), {}, options);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The report of a drift run once it ends: as `applyReport` for apply. */
|
|
628
|
+
async driftReport(runId: string): Promise<DriftReport> {
|
|
629
|
+
return (await this.reportOf(runId, DRIFT_WORKFLOW)) as DriftReport;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Starts the built-in apply workflow, which reads the config's data files itself, with `input`
|
|
634
|
+
* (`only`, `bind`, `answers`; `{}` for all). Returns the run id; `applyReport` waits for its
|
|
635
|
+
* report. Throws `invalid_input` when the config's connectors declare no resource types.
|
|
636
|
+
*/
|
|
637
|
+
async apply(input: ApplyInput, options: StartOptions): Promise<string> {
|
|
638
|
+
return this.start(this.builtIn(APPLY_WORKFLOW), input, options);
|
|
346
639
|
}
|
|
347
640
|
|
|
348
641
|
/**
|
|
349
|
-
* The report of
|
|
642
|
+
* The report of an apply run once it ends. Throws `run_not_found`, `invalid_input` for another
|
|
350
643
|
* workflow's run, the run's error when it failed, and `run_running` when it has not ended
|
|
351
644
|
* within 30 s.
|
|
352
645
|
*/
|
|
353
|
-
async
|
|
646
|
+
async applyReport(runId: string): Promise<ApplyReport> {
|
|
647
|
+
return (await this.reportOf(runId, APPLY_WORKFLOW)) as ApplyReport;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The built-in workflow `name`; `invalid_input` when the config has none, as its connectors declare no resource types. */
|
|
651
|
+
private builtIn(name: string) {
|
|
652
|
+
const workflow = this.config.workflows.get(name);
|
|
653
|
+
if (!workflow) {
|
|
654
|
+
throw new SanomaError(
|
|
655
|
+
"invalid_input",
|
|
656
|
+
`The config's connectors declare no resource types, so it has no built-in ${name} workflow`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
return workflow;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** The output of a run of the built-in `workflow` once it ends. */
|
|
663
|
+
private async reportOf(runId: string, workflow: string): Promise<unknown> {
|
|
354
664
|
const row = await this.mustExist(runId, { loadOutput: true });
|
|
355
|
-
if (row.workflowName !==
|
|
356
|
-
throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${
|
|
665
|
+
if (row.workflowName !== workflow) {
|
|
666
|
+
throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${workflow}`, {
|
|
357
667
|
runId,
|
|
358
668
|
workflow: row.workflowName,
|
|
359
669
|
});
|
|
360
670
|
}
|
|
361
671
|
// A finished run's report came with its row; else wait for the run.
|
|
362
|
-
return
|
|
672
|
+
return row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000);
|
|
363
673
|
}
|
|
364
674
|
|
|
365
675
|
/**
|
|
@@ -367,12 +677,13 @@ export class SanomaClient {
|
|
|
367
677
|
* loads it into its environment, unless its environment sets the variable itself. Refuses
|
|
368
678
|
* (`invalid_input`) a name no driver declares, and a value the variable's schema refuses, with
|
|
369
679
|
* 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.
|
|
680
|
+
* Nothing records the value but its row, which records `by` and when. `by` is admitted as
|
|
681
|
+
* `start`'s `startedBy` is (`not_admitted`).
|
|
371
682
|
*/
|
|
372
683
|
async setCredential(name: string, value: string, options: { by: string }): Promise<void> {
|
|
684
|
+
const setBy = await this.admittedBy(options);
|
|
373
685
|
// Checked against every declaration of the name, as the worker checks it.
|
|
374
686
|
const statuses = this.declared(name, (n) => (n === name ? value : undefined));
|
|
375
|
-
const setBy = actorOf(options);
|
|
376
687
|
const refused = statuses.find((c) => c.status !== "set");
|
|
377
688
|
if (refused) {
|
|
378
689
|
const problem = refused.problem ?? "it is empty; clear it to unset it";
|
|
@@ -387,14 +698,50 @@ export class SanomaClient {
|
|
|
387
698
|
/**
|
|
388
699
|
* Deletes the stored value of a declared variable, and tells the workers, which unset it unless
|
|
389
700
|
* 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.
|
|
701
|
+
* Refuses (`invalid_input`) a name no driver declares, and `by` as `setCredential` does.
|
|
391
702
|
*/
|
|
392
703
|
async clearCredential(name: string, options: { by: string }): Promise<void> {
|
|
704
|
+
await this.admittedBy(options);
|
|
393
705
|
this.declared(name, () => undefined);
|
|
394
|
-
actorOf(options);
|
|
395
706
|
await deleteCredential(await this.credentialsDb(), name);
|
|
396
707
|
}
|
|
397
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
|
|
711
|
+
* a driver or not, such as a data file's `secret("NAME")` or the app's own (its GitHub App's
|
|
712
|
+
* keys). A name a driver declares is checked as `setCredential` checks it; any other name must
|
|
713
|
+
* look like a variable's, and the value must not be empty (`invalid_input`, never the value).
|
|
714
|
+
*/
|
|
715
|
+
async setSecret(name: string, value: string, options: { by: string }): Promise<void> {
|
|
716
|
+
if ([...credentialsOf(this.drivers, () => undefined).values()].flat().some((c) => c.name === name)) {
|
|
717
|
+
return this.setCredential(name, value, options);
|
|
718
|
+
}
|
|
719
|
+
const setBy = await this.admittedBy(options);
|
|
720
|
+
const problem = !VARIABLE.test(name) ? "That is not a variable name" : !value ? "Enter a value" : undefined;
|
|
721
|
+
if (problem) {
|
|
722
|
+
const path = VARIABLE.test(name) ? "value" : "name";
|
|
723
|
+
throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
|
|
724
|
+
}
|
|
725
|
+
await storeCredential(await this.credentialsDb(), { name, value, setBy });
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The values of these secrets, as a driver's `secrets.get` reads them: this process's
|
|
730
|
+
* environment, which wins, else the stored value, with who set it and when. A name with neither
|
|
731
|
+
* is left out. For server code: a page is told whether one is set, never its value.
|
|
732
|
+
*/
|
|
733
|
+
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
734
|
+
const rows = await storedCredentials(await this.credentialsDb(), names);
|
|
735
|
+
const found = new Map<string, StoredSecret>();
|
|
736
|
+
for (const name of names) {
|
|
737
|
+
const env = process.env[name];
|
|
738
|
+
const row = rows.get(name);
|
|
739
|
+
if (env && env !== row?.value) found.set(name, { value: env, source: "environment" });
|
|
740
|
+
else if (row) found.set(name, { ...row, source: "stored" });
|
|
741
|
+
}
|
|
742
|
+
return found;
|
|
743
|
+
}
|
|
744
|
+
|
|
398
745
|
/**
|
|
399
746
|
* Each vendor's declared variables and their statuses now: from this process's environment,
|
|
400
747
|
* which wins, else from the stored credentials, with `source` saying which (a value equal to
|
|
@@ -417,7 +764,12 @@ export class SanomaClient {
|
|
|
417
764
|
}
|
|
418
765
|
|
|
419
766
|
async close() {
|
|
420
|
-
await Promise.all([
|
|
767
|
+
await Promise.all([
|
|
768
|
+
this.dbos.destroy(),
|
|
769
|
+
this.credentialsPool.close(),
|
|
770
|
+
this.audit.close(),
|
|
771
|
+
this.config.dataStore?.close(),
|
|
772
|
+
]);
|
|
421
773
|
}
|
|
422
774
|
|
|
423
775
|
/**
|
|
@@ -445,11 +797,28 @@ export class SanomaClient {
|
|
|
445
797
|
return this.credentialsPool.db();
|
|
446
798
|
}
|
|
447
799
|
|
|
800
|
+
/**
|
|
801
|
+
* The principal as the data files declare it (`admit`): its id alone is taken, never the groups
|
|
802
|
+
* a caller gives, and an id nobody declares is `not_admitted`.
|
|
803
|
+
*/
|
|
804
|
+
private async admitted(principal: Principal): Promise<Principal> {
|
|
805
|
+
return admit(principal, readPeople(this.config.root));
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** `options.by`, who sets or clears a credential, admitted (`admitted`): their id. */
|
|
809
|
+
private async admittedBy(options: { by: string }): Promise<string> {
|
|
810
|
+
const by = parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
|
|
811
|
+
return (await this.admitted({ id: by })).id;
|
|
812
|
+
}
|
|
813
|
+
|
|
448
814
|
/** Refuses a run id whose run, as stored, was started with another input, by someone else, or in another sandbox. */
|
|
449
815
|
private async mustMatch(runId: string, args: RunArgs) {
|
|
450
816
|
const [stored] = ((await this.dbos.getWorkflow(runId))?.input ?? []) as [RunArgs?];
|
|
817
|
+
// Who started it by id alone: their groups are the people files' at the time, which may change.
|
|
451
818
|
const differs = (["input", "startedBy", "sandbox"] as const).filter(
|
|
452
|
-
(key) =>
|
|
819
|
+
(key) =>
|
|
820
|
+
!stored ||
|
|
821
|
+
(key === "startedBy" ? stored.startedBy?.id !== args.startedBy.id : !sameJson(stored[key], args[key])),
|
|
453
822
|
);
|
|
454
823
|
if (differs.length) {
|
|
455
824
|
throw new SanomaError(
|
|
@@ -460,9 +829,15 @@ export class SanomaClient {
|
|
|
460
829
|
}
|
|
461
830
|
}
|
|
462
831
|
|
|
463
|
-
/** The run's status row, read without its input (and its output, unless asked),
|
|
464
|
-
private async
|
|
832
|
+
/** The run's status row, read without its input (and its output, unless asked), if there is one. */
|
|
833
|
+
private async row(runId: string, { loadOutput = false } = {}) {
|
|
465
834
|
const [row] = await this.dbos.listWorkflows({ workflowIDs: [runId], loadInput: false, loadOutput });
|
|
835
|
+
return row;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** The run's status row, as `row` reads it, or `run_not_found`. */
|
|
839
|
+
private async mustExist(runId: string, options: { loadOutput?: boolean } = {}) {
|
|
840
|
+
const row = await this.row(runId, options);
|
|
466
841
|
if (!row) throw new SanomaError("run_not_found", `No run ${runId}`, { runId });
|
|
467
842
|
return row;
|
|
468
843
|
}
|
|
@@ -498,11 +873,18 @@ export class SanomaClient {
|
|
|
498
873
|
}): Promise<RunSummary> {
|
|
499
874
|
const approvals = await this.approvals(r.workflowID);
|
|
500
875
|
const groups = r.authenticatedRoles ?? [];
|
|
876
|
+
const onBehalfOf = r.attributes?.onBehalfOf;
|
|
501
877
|
return {
|
|
502
878
|
runId: r.workflowID,
|
|
503
879
|
workflow: r.workflowName,
|
|
504
880
|
status: runStatus(r.status, approvals),
|
|
505
|
-
startedBy: r.authenticatedUser
|
|
881
|
+
startedBy: r.authenticatedUser
|
|
882
|
+
? {
|
|
883
|
+
id: r.authenticatedUser,
|
|
884
|
+
...(groups.length ? { groups } : {}),
|
|
885
|
+
...(typeof onBehalfOf === "string" && { onBehalfOf }),
|
|
886
|
+
}
|
|
887
|
+
: undefined,
|
|
506
888
|
createdAt: r.createdAt,
|
|
507
889
|
updatedAt: r.updatedAt,
|
|
508
890
|
approvals,
|
|
@@ -512,19 +894,38 @@ export class SanomaClient {
|
|
|
512
894
|
}
|
|
513
895
|
}
|
|
514
896
|
|
|
897
|
+
/**
|
|
898
|
+
* What a direct call's driver returned, as the operation's output schema parses it; a failure
|
|
899
|
+
* with none of the runtime's codes (a reply the schema refuses, an error the driver let escape)
|
|
900
|
+
* is `driver_failed`, naming the operation.
|
|
901
|
+
*/
|
|
902
|
+
async function outputOf(op: Op, call: () => Promise<unknown>): Promise<unknown> {
|
|
903
|
+
try {
|
|
904
|
+
return op.output.parse(await call());
|
|
905
|
+
} catch (err) {
|
|
906
|
+
if (errorCode(err) !== undefined) throw err;
|
|
907
|
+
throw new SanomaError("driver_failed", `${op.id} failed: ${errorMessage(err)}`, { op: op.id }, { cause: err });
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
515
911
|
/** The value as JSON reads it back: a Date as its string, undefined members gone. */
|
|
516
912
|
const asJson = (v: unknown): unknown => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
517
913
|
|
|
518
914
|
/** True when the two are the same JSON, whatever the order of their keys. */
|
|
519
915
|
const sameJson = (a: unknown, b: unknown): boolean => isDeepStrictEqual(asJson(a), asJson(b));
|
|
520
916
|
|
|
917
|
+
/** A secret's value as `SanomaClient.secrets` reads it, and where from. */
|
|
918
|
+
export interface StoredSecret {
|
|
919
|
+
value: string;
|
|
920
|
+
source: "environment" | "stored";
|
|
921
|
+
/** Who stored it (`setSecret`'s `by`), and when, as an ISO 8601 time. */
|
|
922
|
+
setBy?: string;
|
|
923
|
+
setAt?: string;
|
|
924
|
+
}
|
|
925
|
+
|
|
521
926
|
/** What an environment variable's name looks like. */
|
|
522
927
|
const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
|
|
523
928
|
|
|
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
929
|
const alreadyDecided = (runId: string, a: ApprovalState) =>
|
|
529
930
|
new SanomaError("already_decided", `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
|
|
530
931
|
runId,
|