@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.
Files changed (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. package/src/writer.ts +225 -0
package/dist/client.js CHANGED
@@ -3,15 +3,29 @@ import { isDeepStrictEqual } from "node:util";
3
3
  import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
4
4
  import { z } from "zod";
5
5
  import { APPROVALS_EVENT, ApprovalMessage, decisionEventOf, messageKeyOf, topicOf } from "./approvals.js";
6
+ import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.js";
7
+ import { collectionsDriver } from "./collection.js";
6
8
  import { resolveConfig } from "./config.js";
7
- import { deleteCredential, storeCredential, storedCredentials } from "./credentials.js";
9
+ import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.js";
8
10
  import { dbosClient, lazyPool } from "./db.js";
9
11
  import { credentialsOf } from "./env.js";
10
- import { notApprover, Principal } from "./define.js";
12
+ import { Principal } from "./define.js";
13
+ import { APPLY_WORKFLOW } from "./apply.js";
11
14
  import { DRIFT_WORKFLOW } from "./drift.js";
12
- import { parseOrThrow, SanomaError } from "./errors.js";
13
- import { ownedLedger } from "./ledger.js";
14
- import { isEnded, mayDecide } from "./shared.js";
15
+ import { isDirect } from "./direct.js";
16
+ import { ApprovalPendingError, errorCode, errorInfo, parseOrThrow, PolicyDeniedError, SanomaError, } from "./errors.js";
17
+ import { insertRecord, ownedLedger } from "./ledger.js";
18
+ import { allowAll } from "./policy.js";
19
+ import { admit, readPeople } from "./resources.js";
20
+ import { BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR, errorMessage, isEnded, refusalOf, WORKFLOW_ACTION, } from "./shared.js";
21
+ /** A principal, or its id alone. */
22
+ const PrincipalOrId = z.union([
23
+ z
24
+ .string()
25
+ .regex(/\S/)
26
+ .transform((id) => ({ id })),
27
+ Principal,
28
+ ]);
15
29
  /**
16
30
  * The run status each DBOS status maps to. `waiting` is PENDING with an approval pending, so
17
31
  * only the approvals tell it from `running`. Keyed by every DBOS status, so one a later DBOS
@@ -56,12 +70,15 @@ export class SanomaClient {
56
70
  audit;
57
71
  /** On the config's database, for the credentials table: opened on first use. */
58
72
  credentialsPool;
73
+ /** What a direct call's driver reads secrets from, as a worker's live driver does. */
74
+ storedSecrets;
59
75
  constructor(dbos, config, drivers) {
60
76
  this.dbos = dbos;
61
77
  this.config = config;
62
78
  this.drivers = drivers;
63
79
  this.audit = ownedLedger(config);
64
80
  this.credentialsPool = lazyPool(config.databaseUrl, "credentials");
81
+ this.storedSecrets = storedSecrets(() => this.credentialsDb(), "sanoma");
65
82
  }
66
83
  /**
67
84
  * Connects to the runtime's Postgres for the config's app. Checks the config first and throws
@@ -80,6 +97,7 @@ export class SanomaClient {
80
97
  * `workflow` is a definition, or the name of one in the config's `workflows`.
81
98
  */
82
99
  async start(workflow, input, options) {
100
+ const startedBy = await this.admitted(parseOrThrow(PrincipalOrId, options?.startedBy, '`startedBy` must be a principal, such as "alice" or { id: "alice" }'));
83
101
  if (typeof workflow === "string") {
84
102
  const named = this.config.workflows.get(workflow);
85
103
  if (!named) {
@@ -91,11 +109,13 @@ export class SanomaClient {
91
109
  }
92
110
  workflow = named;
93
111
  }
94
- parseOrThrow(workflow.input, input, `The input does not match ${workflow.name}'s schema`);
95
- const startedBy = parseOrThrow(Principal, options?.startedBy, '`startedBy` must be a principal, such as { id: "alice" }');
112
+ // The policy decides the start on the input as parsed, its defaults filled, as the Cedar schema types it.
113
+ const parsed = parseOrThrow(workflow.input, input, `The input does not match ${workflow.name}'s schema`);
96
114
  const { runId } = options;
97
115
  const sandbox = parseOrThrow(z.string().min(1).optional(), options.sandbox, "`sandbox` must be the name of a scenario in the config's `scenarios/`");
98
- const args = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }) };
116
+ const hold = await this.startHold(workflow.name, parsed, startedBy, runId);
117
+ const args = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
118
+ const { onBehalfOf } = startedBy;
99
119
  // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
100
120
  const handle = await this.dbos
101
121
  .enqueue({
@@ -105,8 +125,9 @@ export class SanomaClient {
105
125
  applicationName: this.config.appName,
106
126
  authenticatedUser: startedBy.id,
107
127
  authenticatedRoles: startedBy.groups ?? [],
108
- // Kept on the run's status row too, so a listing tells sandbox runs apart without its input.
109
- ...(sandbox === undefined ? {} : { attributes: { sandbox } }),
128
+ // Kept on the run's status row too, so a listing tells sandbox runs apart without its input,
129
+ // and says whom an agent's run is for (DBOS keeps the principal's id and groups itself).
130
+ ...((sandbox !== undefined || onBehalfOf !== undefined) && { attributes: { sandbox, onBehalfOf } }),
110
131
  }, args)
111
132
  .catch((err) => {
112
133
  if (runId !== undefined && err instanceof DBOSErrors.DBOSConflictingWorkflowError) {
@@ -119,6 +140,200 @@ export class SanomaClient {
119
140
  await this.mustMatch(runId, args);
120
141
  return handle.workflowID;
121
142
  }
143
+ /**
144
+ * The policy's answer to starting a run of `workflow` as `startedBy` (`decideStart`), for a
145
+ * workflow of the config's own: a deny throws `policy_denied`; a hold is what the run waits on
146
+ * before its body. A run `runId` names already is returned as it is, whatever the policy says now.
147
+ */
148
+ async startHold(workflow, input, startedBy, runId) {
149
+ const { policy } = this.config;
150
+ if (BUILT_IN_WORKFLOWS.has(workflow) || policy === allowAll)
151
+ return undefined;
152
+ if (runId !== undefined && (await this.row(runId)))
153
+ return undefined;
154
+ const decision = decideStart(policy, workflow, input, startedBy);
155
+ if (decision.kind === "allow")
156
+ return undefined;
157
+ if (decision.kind === "deny")
158
+ throw new PolicyDeniedError(`${WORKFLOW_ACTION}${workflow}`, decision.reason);
159
+ return startHoldOf(workflow, decision);
160
+ }
161
+ /**
162
+ * Calls an operation directly, outside a run, as `actor`: one that may be (`isDirect`), a
163
+ * collection's or a vendor's read, never a vendor's write, the runtime's own or a resource
164
+ * type's (`invalid_input`, as is an unknown id or input its schema refuses). The policy decides
165
+ * it as the person, in a run `direct:<actor>:<id>` of the workflow `call` with no approvals. A
166
+ * deny throws `policy_denied` and writes nothing. An allowed call runs the driver once, without
167
+ * DBOS and without a retry (a driver's failure is `driver_failed`). A read returns its output
168
+ * and records nothing. A write is recorded as one `op.called` (seq 0) in that run, and with the
169
+ * default ledger in one transaction with the record it saves, so both are written or neither;
170
+ * a write with the same id and input returns the output recorded. When the policy asks for an
171
+ * approval, the call becomes run `call:<actor>:<id>` of the built-in `call` workflow, started
172
+ * as the person, which makes it once approved, and this throws `approval_pending` with its id.
173
+ */
174
+ async call(opId, input, options) {
175
+ const actor = await this.admitted(parseOrThrow(PrincipalOrId, options?.actor, '`actor` must be a principal, such as "alice" or { id: "alice" }'));
176
+ const id = parseOrThrow(z.string().min(1).optional(), options.id, "`id` must be a non-empty string") ?? randomUUID();
177
+ const { ops, types, drivers, policy } = this.config;
178
+ const op = ops.get(opId);
179
+ if (!op || !isDirect(op, types)) {
180
+ const why = op ? "it is a workflow's to call: start one that uses it" : "no connector or collection declares it";
181
+ throw new SanomaError("invalid_input", `${opId} cannot be called directly: ${why}`, { op: opId });
182
+ }
183
+ const fn = drivers.get(op.id);
184
+ if (!fn)
185
+ throw new SanomaError("invalid_input", `${op.id} has no driver: the connector package must attach one`);
186
+ const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
187
+ const runId = `direct:${actor.id}:${id}`;
188
+ const heldId = `call:${actor.id}:${id}`;
189
+ const held = { input: { op: op.id, input }, startedBy: actor };
190
+ const logged = recorded(op, parsed);
191
+ const write = op.effect !== "read";
192
+ // Only an id the caller gave can have been used: a write made, or held, with it settles it.
193
+ if (write && options.id !== undefined) {
194
+ const first = (await this.recordedCall(runId, op.id, logged)) ?? (await this.heldCall(op.id, heldId, held));
195
+ if (first)
196
+ return first.output;
197
+ }
198
+ const before = await beforeOf(policy, op, parsed, this.config.collections, (get, key) => drivers.get(get)?.({ key }, { idempotencyKey: `${runId}:before`, runId, opId: get, attempt: 1, secrets: this.storedSecrets, by: actor.id }));
199
+ const run = { id: runId, workflow: CALL_WORKFLOW, approvals: [] };
200
+ const decision = decideCall(policy, op, parsed, actor, run, before);
201
+ if (decision.kind === "deny")
202
+ throw new PolicyDeniedError(op.id, decision.reason);
203
+ if (decision.kind === "approve") {
204
+ await this.start(CALL_WORKFLOW, held.input, { startedBy: actor, runId: heldId });
205
+ // A read held before, whose run has ended since, answers with that run's result.
206
+ return (await this.heldCall(op.id, heldId, held)).output;
207
+ }
208
+ const context = {
209
+ idempotencyKey: runId,
210
+ runId,
211
+ opId: op.id,
212
+ attempt: 1,
213
+ secrets: this.storedSecrets,
214
+ by: actor.id,
215
+ };
216
+ if (!write)
217
+ return outputOf(op, () => fn(parsed, context));
218
+ return this.directWrite({ op, fn, parsed, logged, actor, decision, context });
219
+ }
220
+ /**
221
+ * A direct write, made once and recorded once: with the default ledger, a collection's write
222
+ * and its record in one transaction on the database both are in, so both are kept or neither;
223
+ * otherwise the driver's call, then the record in the config's ledger. The first record of the
224
+ * call's id stands: a write that finds one already there (two calls with one id at once) is
225
+ * undone, and answers as `recordedCall` does.
226
+ */
227
+ async directWrite(call) {
228
+ const { op, parsed, logged, actor, decision, context } = call;
229
+ const { runId } = context;
230
+ const { dataStore, collections, ledger } = this.config;
231
+ const conn = op.vendor === DATA_VENDOR && !ledger && dataStore ? await (await dataStore.db()).connect() : undefined;
232
+ let broken;
233
+ try {
234
+ await conn?.query("BEGIN");
235
+ // On the transaction's connection, the collection's own driver: it has no `env` to guard.
236
+ const fn = conn
237
+ ? collectionsDriver([collections.get(op.resource)], { db: async () => conn }).ops[`${op.resource}.${op.name}`]
238
+ : call.fn;
239
+ const at = Date.now();
240
+ const record = (outcome) => ({
241
+ v: 1,
242
+ app: this.config.appName,
243
+ id: `${runId}:op.called:0`,
244
+ runId,
245
+ seq: 0,
246
+ at,
247
+ actor,
248
+ workflow: CALL_WORKFLOW,
249
+ type: "op.called",
250
+ op: op.id,
251
+ effect: op.effect,
252
+ input: logged,
253
+ decision,
254
+ ...outcome,
255
+ durationMs: Date.now() - at,
256
+ });
257
+ let output;
258
+ try {
259
+ output = await outputOf(op, () => fn(parsed, context));
260
+ }
261
+ catch (err) {
262
+ // Recorded on the store's own connection; the transaction is rolled back below.
263
+ await this.audit.store.append(record({ error: errorInfo(err) }));
264
+ throw err;
265
+ }
266
+ const done = record({ output: recorded(op, output) });
267
+ if (!conn) {
268
+ await this.audit.store.append(done);
269
+ return output;
270
+ }
271
+ if (await insertRecord(conn, done)) {
272
+ await conn.query("COMMIT");
273
+ return output;
274
+ }
275
+ await conn.query("ROLLBACK");
276
+ return (await this.recordedCall(runId, op.id, logged)).output;
277
+ }
278
+ catch (err) {
279
+ await conn?.query("ROLLBACK").catch((e) => (broken = e));
280
+ throw err;
281
+ }
282
+ finally {
283
+ conn?.release(broken);
284
+ }
285
+ }
286
+ /**
287
+ * What the direct write recorded as `runId` returned, when one did: refused (`invalid_input`)
288
+ * when it was of another operation or input, and its failure again (`driver_failed`) when it failed.
289
+ */
290
+ async recordedCall(runId, opId, input) {
291
+ const first = (await this.audit.store.read(runId)).find((r) => r.type === "op.called");
292
+ if (!first)
293
+ return undefined;
294
+ const differs = [...(first.op === opId ? [] : ["op"]), ...(sameJson(first.input, input) ? [] : ["input"])];
295
+ if (differs.length) {
296
+ throw new SanomaError("invalid_input", `Call ${runId} was made already with a different ${differs.join(" and ")}; use another id`, { runId, differs });
297
+ }
298
+ if (first.error) {
299
+ throw new SanomaError("driver_failed", `${opId} failed when first called as ${runId}: ${first.error.message}; call again with another id`, {
300
+ runId,
301
+ op: opId,
302
+ error: first.error,
303
+ });
304
+ }
305
+ return { output: first.output };
306
+ }
307
+ /**
308
+ * What the held call's run `heldId` returned once it ended (its error, when it failed);
309
+ * `approval_pending` while it has not; undefined when there is no such run. Refused
310
+ * (`invalid_input`) when the run was started with another input or by someone else.
311
+ */
312
+ async heldCall(opId, heldId, args) {
313
+ const [row] = await this.dbos.listWorkflows({ workflowIDs: [heldId], loadInput: false, loadOutput: false });
314
+ if (!row)
315
+ return undefined;
316
+ await this.mustMatch(heldId, args);
317
+ if (!isEnded(runStatus(row.status, [])))
318
+ throw new ApprovalPendingError(opId, heldId);
319
+ return { output: await this.awaitResult(heldId, 30_000) };
320
+ }
321
+ /**
322
+ * The newest finished run of the workflow, with its output and when it finished; null when it
323
+ * has none. One query, whatever the app's runs.
324
+ */
325
+ async latest(workflow) {
326
+ const [row] = await this.dbos.listWorkflows({
327
+ workflowName: workflow,
328
+ applicationName: this.config.appName,
329
+ status: "SUCCESS",
330
+ sortDesc: true,
331
+ limit: 1,
332
+ loadInput: false,
333
+ loadOutput: true,
334
+ });
335
+ return row ? { runId: row.workflowID, output: row.output, at: row.updatedAt ?? row.createdAt } : null;
336
+ }
122
337
  /** The run's audit record, in order, from the ledger store. */
123
338
  async ledger(runId) {
124
339
  await this.mustExist(runId);
@@ -166,7 +381,9 @@ export class SanomaClient {
166
381
  * queued for the run, which reads it when it next runs.
167
382
  */
168
383
  async decide(runId, message, approvalId, options = {}) {
169
- const msg = parseOrThrow(ApprovalMessage, message, "Not a decision");
384
+ const given = parseOrThrow(ApprovalMessage, message, "Not a decision");
385
+ const declared = readPeople(this.config.root);
386
+ const msg = { ...given, by: admit(given.by, declared) };
170
387
  // A run that does not exist has no approvals, so run_not_found never hides already_decided.
171
388
  const [all, row] = await Promise.all([this.approvals(runId), this.mustExist(runId)]);
172
389
  const target = approvalId ? all.find((a) => a.id === approvalId) : all.find((a) => a.status === "pending");
@@ -184,11 +401,13 @@ export class SanomaClient {
184
401
  if (!target) {
185
402
  throw new SanomaError("no_pending_approval", `Run ${runId} has no pending approval${approvalId ? ` "${approvalId}"` : ""}`, { runId, ...(approvalId ? { approvalId } : {}) });
186
403
  }
187
- if (!mayDecide(target, msg.by)) {
188
- throw new SanomaError("not_approver", `${notApprover(target, msg.by)} (${target.id})`, {
404
+ const reason = refusalOf(target, msg.by, { actor: row.authenticatedUser ? { id: row.authenticatedUser } : undefined }, declared.people);
405
+ if (reason !== undefined) {
406
+ throw new SanomaError("not_approver", `${reason} (${target.id})`, {
189
407
  runId,
190
408
  approvalId: target.id,
191
409
  approver: target.approver,
410
+ reason,
192
411
  });
193
412
  }
194
413
  // The id names this message, so the run's answer says whether it decided with it, and is
@@ -215,6 +434,7 @@ export class SanomaClient {
215
434
  runId,
216
435
  approvalId: target.id,
217
436
  approver: target.approver,
437
+ reason: refused.reason,
218
438
  });
219
439
  }
220
440
  if (now.status !== "pending" && now.decidedWith !== id)
@@ -235,39 +455,60 @@ export class SanomaClient {
235
455
  * `invalid_input` when the config's connectors declare no resource types, so it has none.
236
456
  */
237
457
  async drift(options) {
238
- const drift = this.config.workflows.get(DRIFT_WORKFLOW);
239
- if (!drift) {
240
- throw new SanomaError("invalid_input", "The config's connectors declare no resource types, so there is nothing to check for drift");
241
- }
242
- return this.start(drift, {}, options);
458
+ return this.start(this.builtIn(DRIFT_WORKFLOW), {}, options);
459
+ }
460
+ /** The report of a drift run once it ends: as `applyReport` for apply. */
461
+ async driftReport(runId) {
462
+ return (await this.reportOf(runId, DRIFT_WORKFLOW));
463
+ }
464
+ /**
465
+ * Starts the built-in apply workflow, which reads the config's data files itself, with `input`
466
+ * (`only`, `bind`, `answers`; `{}` for all). Returns the run id; `applyReport` waits for its
467
+ * report. Throws `invalid_input` when the config's connectors declare no resource types.
468
+ */
469
+ async apply(input, options) {
470
+ return this.start(this.builtIn(APPLY_WORKFLOW), input, options);
243
471
  }
244
472
  /**
245
- * The report of a drift run once it ends. Throws `run_not_found`, `invalid_input` for another
473
+ * The report of an apply run once it ends. Throws `run_not_found`, `invalid_input` for another
246
474
  * workflow's run, the run's error when it failed, and `run_running` when it has not ended
247
475
  * within 30 s.
248
476
  */
249
- async driftReport(runId) {
477
+ async applyReport(runId) {
478
+ return (await this.reportOf(runId, APPLY_WORKFLOW));
479
+ }
480
+ /** The built-in workflow `name`; `invalid_input` when the config has none, as its connectors declare no resource types. */
481
+ builtIn(name) {
482
+ const workflow = this.config.workflows.get(name);
483
+ if (!workflow) {
484
+ throw new SanomaError("invalid_input", `The config's connectors declare no resource types, so it has no built-in ${name} workflow`);
485
+ }
486
+ return workflow;
487
+ }
488
+ /** The output of a run of the built-in `workflow` once it ends. */
489
+ async reportOf(runId, workflow) {
250
490
  const row = await this.mustExist(runId, { loadOutput: true });
251
- if (row.workflowName !== DRIFT_WORKFLOW) {
252
- throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${DRIFT_WORKFLOW}`, {
491
+ if (row.workflowName !== workflow) {
492
+ throw new SanomaError("invalid_input", `Run ${runId} is a run of ${row.workflowName}, not of ${workflow}`, {
253
493
  runId,
254
494
  workflow: row.workflowName,
255
495
  });
256
496
  }
257
497
  // A finished run's report came with its row; else wait for the run.
258
- return (row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000));
498
+ return row.status === "SUCCESS" ? row.output : await this.awaitResult(runId, 30_000);
259
499
  }
260
500
  /**
261
501
  * Stores a value for a variable a driver's `env` declares, as `by`, and tells the workers: each
262
502
  * loads it into its environment, unless its environment sets the variable itself. Refuses
263
503
  * (`invalid_input`) a name no driver declares, and a value the variable's schema refuses, with
264
504
  * the schema's reason and never the value; an empty value too (`clearCredential` unsets one).
265
- * Nothing records the value but its row, which records `by` and when.
505
+ * Nothing records the value but its row, which records `by` and when. `by` is admitted as
506
+ * `start`'s `startedBy` is (`not_admitted`).
266
507
  */
267
508
  async setCredential(name, value, options) {
509
+ const setBy = await this.admittedBy(options);
268
510
  // Checked against every declaration of the name, as the worker checks it.
269
511
  const statuses = this.declared(name, (n) => (n === name ? value : undefined));
270
- const setBy = actorOf(options);
271
512
  const refused = statuses.find((c) => c.status !== "set");
272
513
  if (refused) {
273
514
  const problem = refused.problem ?? "it is empty; clear it to unset it";
@@ -281,13 +522,49 @@ export class SanomaClient {
281
522
  /**
282
523
  * Deletes the stored value of a declared variable, and tells the workers, which unset it unless
283
524
  * their environment sets it. `by` is who asks; nothing records it once the row is gone.
284
- * Refuses (`invalid_input`) a name no driver declares.
525
+ * Refuses (`invalid_input`) a name no driver declares, and `by` as `setCredential` does.
285
526
  */
286
527
  async clearCredential(name, options) {
528
+ await this.admittedBy(options);
287
529
  this.declared(name, () => undefined);
288
- actorOf(options);
289
530
  await deleteCredential(await this.credentialsDb(), name);
290
531
  }
532
+ /**
533
+ * Stores a secret as `by`, and tells the workers: a value under any variable's name, declared by
534
+ * a driver or not, such as a data file's `secret("NAME")` or the app's own (its GitHub App's
535
+ * keys). A name a driver declares is checked as `setCredential` checks it; any other name must
536
+ * look like a variable's, and the value must not be empty (`invalid_input`, never the value).
537
+ */
538
+ async setSecret(name, value, options) {
539
+ if ([...credentialsOf(this.drivers, () => undefined).values()].flat().some((c) => c.name === name)) {
540
+ return this.setCredential(name, value, options);
541
+ }
542
+ const setBy = await this.admittedBy(options);
543
+ const problem = !VARIABLE.test(name) ? "That is not a variable name" : !value ? "Enter a value" : undefined;
544
+ if (problem) {
545
+ const path = VARIABLE.test(name) ? "value" : "name";
546
+ throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
547
+ }
548
+ await storeCredential(await this.credentialsDb(), { name, value, setBy });
549
+ }
550
+ /**
551
+ * The values of these secrets, as a driver's `secrets.get` reads them: this process's
552
+ * environment, which wins, else the stored value, with who set it and when. A name with neither
553
+ * is left out. For server code: a page is told whether one is set, never its value.
554
+ */
555
+ async secrets(names) {
556
+ const rows = await storedCredentials(await this.credentialsDb(), names);
557
+ const found = new Map();
558
+ for (const name of names) {
559
+ const env = process.env[name];
560
+ const row = rows.get(name);
561
+ if (env && env !== row?.value)
562
+ found.set(name, { value: env, source: "environment" });
563
+ else if (row)
564
+ found.set(name, { ...row, source: "stored" });
565
+ }
566
+ return found;
567
+ }
291
568
  /**
292
569
  * Each vendor's declared variables and their statuses now: from this process's environment,
293
570
  * which wins, else from the stored credentials, with `source` saying which (a value equal to
@@ -311,7 +588,12 @@ export class SanomaClient {
311
588
  return new Map([...statuses].map(([vendor, list]) => [vendor, list.map(sourced)]));
312
589
  }
313
590
  async close() {
314
- await Promise.all([this.dbos.destroy(), this.credentialsPool.close(), this.audit.close()]);
591
+ await Promise.all([
592
+ this.dbos.destroy(),
593
+ this.credentialsPool.close(),
594
+ this.audit.close(),
595
+ this.config.dataStore?.close(),
596
+ ]);
315
597
  }
316
598
  /**
317
599
  * The name's statuses with `lookup`'s value, one per vendor whose drivers declare it, or
@@ -335,17 +617,36 @@ export class SanomaClient {
335
617
  credentialsDb() {
336
618
  return this.credentialsPool.db();
337
619
  }
620
+ /**
621
+ * The principal as the data files declare it (`admit`): its id alone is taken, never the groups
622
+ * a caller gives, and an id nobody declares is `not_admitted`.
623
+ */
624
+ async admitted(principal) {
625
+ return admit(principal, readPeople(this.config.root));
626
+ }
627
+ /** `options.by`, who sets or clears a credential, admitted (`admitted`): their id. */
628
+ async admittedBy(options) {
629
+ const by = parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
630
+ return (await this.admitted({ id: by })).id;
631
+ }
338
632
  /** Refuses a run id whose run, as stored, was started with another input, by someone else, or in another sandbox. */
339
633
  async mustMatch(runId, args) {
340
634
  const [stored] = ((await this.dbos.getWorkflow(runId))?.input ?? []);
341
- const differs = ["input", "startedBy", "sandbox"].filter((key) => !stored || !sameJson(stored[key], args[key]));
635
+ // Who started it by id alone: their groups are the people files' at the time, which may change.
636
+ const differs = ["input", "startedBy", "sandbox"].filter((key) => !stored ||
637
+ (key === "startedBy" ? stored.startedBy?.id !== args.startedBy.id : !sameJson(stored[key], args[key])));
342
638
  if (differs.length) {
343
639
  throw new SanomaError("invalid_input", `Run ${runId} already exists with a different ${differs.join(", ")}; use another run id`, { runId, differs });
344
640
  }
345
641
  }
346
- /** The run's status row, read without its input (and its output, unless asked), or `run_not_found`. */
347
- async mustExist(runId, { loadOutput = false } = {}) {
642
+ /** The run's status row, read without its input (and its output, unless asked), if there is one. */
643
+ async row(runId, { loadOutput = false } = {}) {
348
644
  const [row] = await this.dbos.listWorkflows({ workflowIDs: [runId], loadInput: false, loadOutput });
645
+ return row;
646
+ }
647
+ /** The run's status row, as `row` reads it, or `run_not_found`. */
648
+ async mustExist(runId, options = {}) {
649
+ const row = await this.row(runId, options);
349
650
  if (!row)
350
651
  throw new SanomaError("run_not_found", `No run ${runId}`, { runId });
351
652
  return row;
@@ -370,11 +671,18 @@ export class SanomaClient {
370
671
  async summarize(r) {
371
672
  const approvals = await this.approvals(r.workflowID);
372
673
  const groups = r.authenticatedRoles ?? [];
674
+ const onBehalfOf = r.attributes?.onBehalfOf;
373
675
  return {
374
676
  runId: r.workflowID,
375
677
  workflow: r.workflowName,
376
678
  status: runStatus(r.status, approvals),
377
- startedBy: r.authenticatedUser ? { id: r.authenticatedUser, ...(groups.length ? { groups } : {}) } : undefined,
679
+ startedBy: r.authenticatedUser
680
+ ? {
681
+ id: r.authenticatedUser,
682
+ ...(groups.length ? { groups } : {}),
683
+ ...(typeof onBehalfOf === "string" && { onBehalfOf }),
684
+ }
685
+ : undefined,
378
686
  createdAt: r.createdAt,
379
687
  updatedAt: r.updatedAt,
380
688
  approvals,
@@ -383,14 +691,27 @@ export class SanomaClient {
383
691
  };
384
692
  }
385
693
  }
694
+ /**
695
+ * What a direct call's driver returned, as the operation's output schema parses it; a failure
696
+ * with none of the runtime's codes (a reply the schema refuses, an error the driver let escape)
697
+ * is `driver_failed`, naming the operation.
698
+ */
699
+ async function outputOf(op, call) {
700
+ try {
701
+ return op.output.parse(await call());
702
+ }
703
+ catch (err) {
704
+ if (errorCode(err) !== undefined)
705
+ throw err;
706
+ throw new SanomaError("driver_failed", `${op.id} failed: ${errorMessage(err)}`, { op: op.id }, { cause: err });
707
+ }
708
+ }
386
709
  /** The value as JSON reads it back: a Date as its string, undefined members gone. */
387
710
  const asJson = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
388
711
  /** True when the two are the same JSON, whatever the order of their keys. */
389
712
  const sameJson = (a, b) => isDeepStrictEqual(asJson(a), asJson(b));
390
713
  /** What an environment variable's name looks like. */
391
714
  const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
392
- /** `options.by`, who sets or clears a credential: a name, or `invalid_input`. */
393
- const actorOf = (options) => parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
394
715
  const alreadyDecided = (runId, a) => new SanomaError("already_decided", `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
395
716
  runId,
396
717
  approvalId: a.id,