@sanoma-ai/workflows 0.2.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 (144) hide show
  1. package/README.md +121 -62
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +10 -4
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/index.d.ts +8 -4
  51. package/dist/index.js +10 -4
  52. package/dist/index.js.map +1 -1
  53. package/dist/ledger.d.ts +12 -1
  54. package/dist/ledger.js +18 -11
  55. package/dist/ledger.js.map +1 -1
  56. package/dist/lint.d.ts +5 -4
  57. package/dist/lint.js +24 -7
  58. package/dist/lint.js.map +1 -1
  59. package/dist/op.d.ts +6 -0
  60. package/dist/op.js.map +1 -1
  61. package/dist/outline.d.ts +2 -0
  62. package/dist/outline.js +1 -1
  63. package/dist/outline.js.map +1 -1
  64. package/dist/paths.d.ts +4 -0
  65. package/dist/paths.js +17 -3
  66. package/dist/paths.js.map +1 -1
  67. package/dist/person.d.ts +61 -0
  68. package/dist/person.js +69 -0
  69. package/dist/person.js.map +1 -0
  70. package/dist/plugin.js +1 -1
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/policy.d.ts +51 -55
  73. package/dist/policy.js +14 -36
  74. package/dist/policy.js.map +1 -1
  75. package/dist/resource.d.ts +21 -5
  76. package/dist/resource.js +12 -3
  77. package/dist/resource.js.map +1 -1
  78. package/dist/resources.d.ts +58 -6
  79. package/dist/resources.js +119 -26
  80. package/dist/resources.js.map +1 -1
  81. package/dist/run.d.ts +17 -1
  82. package/dist/run.js.map +1 -1
  83. package/dist/scenario.d.ts +16 -9
  84. package/dist/scenario.js +93 -20
  85. package/dist/scenario.js.map +1 -1
  86. package/dist/scenarios.d.ts +10 -6
  87. package/dist/scenarios.js +8 -4
  88. package/dist/scenarios.js.map +1 -1
  89. package/dist/schema.d.ts +32 -0
  90. package/dist/schema.js +225 -0
  91. package/dist/schema.js.map +1 -0
  92. package/dist/sdk.d.ts +34 -0
  93. package/dist/sdk.js +168 -0
  94. package/dist/sdk.js.map +1 -0
  95. package/dist/shared.d.ts +181 -5
  96. package/dist/shared.js +160 -7
  97. package/dist/shared.js.map +1 -1
  98. package/dist/sites.d.ts +52 -0
  99. package/dist/sites.js +174 -0
  100. package/dist/sites.js.map +1 -0
  101. package/dist/version.d.ts +3 -2
  102. package/dist/version.js +3 -2
  103. package/dist/version.js.map +1 -1
  104. package/dist/worker.d.ts +3 -2
  105. package/dist/worker.js +24 -4
  106. package/dist/worker.js.map +1 -1
  107. package/oxlint.json +5 -16
  108. package/package.json +8 -3
  109. package/src/approvals.ts +49 -13
  110. package/src/call-workflow.ts +44 -0
  111. package/src/call.ts +157 -45
  112. package/src/cedar.ts +493 -0
  113. package/src/client.ts +368 -37
  114. package/src/collection.ts +431 -0
  115. package/src/config.ts +131 -34
  116. package/src/constructors.ts +4 -0
  117. package/src/data.ts +10 -0
  118. package/src/datafile.ts +141 -21
  119. package/src/db.ts +90 -22
  120. package/src/define.ts +11 -9
  121. package/src/describe.ts +113 -10
  122. package/src/direct.ts +15 -0
  123. package/src/errors.ts +36 -1
  124. package/src/expectations.ts +17 -8
  125. package/src/index.ts +33 -2
  126. package/src/ledger.ts +35 -17
  127. package/src/lint.ts +29 -12
  128. package/src/op.ts +6 -0
  129. package/src/outline.ts +1 -1
  130. package/src/paths.ts +18 -3
  131. package/src/person.ts +75 -0
  132. package/src/plugin.ts +1 -1
  133. package/src/policy.ts +59 -63
  134. package/src/resource.ts +35 -8
  135. package/src/resources.ts +165 -24
  136. package/src/run.ts +18 -1
  137. package/src/scenario.ts +128 -33
  138. package/src/scenarios.ts +15 -8
  139. package/src/schema.ts +261 -0
  140. package/src/sdk.ts +211 -0
  141. package/src/shared.ts +271 -8
  142. package/src/sites.ts +220 -0
  143. package/src/version.ts +3 -2
  144. package/src/worker.ts +29 -6
package/src/client.ts CHANGED
@@ -4,18 +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, notApprover, Principal, type WorkflowDefinition } from "./define.ts";
13
+ import { type ApprovalState, Principal, type WorkflowDefinition } from "./define.ts";
12
14
  import { APPLY_WORKFLOW, type ApplyInput, type ApplyReport } from "./apply.ts";
13
15
  import { DRIFT_WORKFLOW, type DriftReport } from "./drift.ts";
14
- import { parseOrThrow, SanomaError } from "./errors.ts";
15
- import { type LedgerRecord, type OwnedLedger, ownedLedger } from "./ledger.ts";
16
- import type { Driver } from "./op.ts";
17
- import type { RunArgs } from "./run.ts";
18
- import { isEnded, mayDecide, type RunStatus } from "./shared.ts";
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
+ ]);
19
51
 
20
52
  /**
21
53
  * The run status each DBOS status maps to. `waiting` is PENDING with an approval pending, so
@@ -77,7 +109,7 @@ export interface RunSummary {
77
109
  runId: string;
78
110
  workflow: string;
79
111
  status: RunStatus;
80
- /** Who started the run, as passed to `start`. */
112
+ /** Who started the run, as `start` admitted them. */
81
113
  startedBy?: Principal;
82
114
  createdAt: number;
83
115
  updatedAt?: number;
@@ -88,12 +120,17 @@ export interface RunSummary {
88
120
  }
89
121
 
90
122
  export interface StartOptions {
91
- /** Recorded as the run's actor in the ledger and passed to the policy. */
92
- startedBy: Principal;
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;
93
130
  /**
94
131
  * Use this run id instead of a new one, so a retried start makes one run. Starting an id
95
132
  * that exists returns it, without starting another, when the workflow, the input (compared
96
- * as JSON), `startedBy` and `sandbox` are the same, and throws `invalid_input` naming what differs
133
+ * as JSON), `startedBy`'s id and `sandbox` are the same, and throws `invalid_input` naming what differs
97
134
  * when they are not. Of two starts racing with one new id, the first's run stands.
98
135
  */
99
136
  runId?: string;
@@ -105,6 +142,20 @@ export interface StartOptions {
105
142
  sandbox?: string;
106
143
  }
107
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
+
108
159
  /** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
109
160
  export class SanomaClient {
110
161
  private readonly dbos: DBOSClient;
@@ -114,6 +165,8 @@ export class SanomaClient {
114
165
  private readonly audit: OwnedLedger;
115
166
  /** On the config's database, for the credentials table: opened on first use. */
116
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;
117
170
 
118
171
  private constructor(dbos: DBOSClient, config: ResolvedConfig, drivers: ReadonlyMap<string, Driver>) {
119
172
  this.dbos = dbos;
@@ -121,6 +174,7 @@ export class SanomaClient {
121
174
  this.drivers = drivers;
122
175
  this.audit = ownedLedger(config);
123
176
  this.credentialsPool = lazyPool(config.databaseUrl, "credentials");
177
+ this.storedSecrets = storedSecrets(() => this.credentialsDb(), "sanoma");
124
178
  }
125
179
 
126
180
  /**
@@ -145,6 +199,13 @@ export class SanomaClient {
145
199
  input: z.input<S>,
146
200
  options: StartOptions,
147
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
+ );
148
209
  if (typeof workflow === "string") {
149
210
  const named = this.config.workflows.get(workflow);
150
211
  if (!named) {
@@ -156,19 +217,17 @@ export class SanomaClient {
156
217
  }
157
218
  workflow = named;
158
219
  }
159
- parseOrThrow(workflow.input, input, `The input does not match ${workflow.name}'s schema`);
160
- const startedBy = parseOrThrow(
161
- Principal,
162
- options?.startedBy,
163
- '`startedBy` must be a principal, such as { id: "alice" }',
164
- );
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`);
165
222
  const { runId } = options;
166
223
  const sandbox = parseOrThrow(
167
224
  z.string().min(1).optional(),
168
225
  options.sandbox,
169
226
  "`sandbox` must be the name of a scenario in the config's `scenarios/`",
170
227
  );
171
- const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }) };
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;
172
231
  // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
173
232
  const handle = await this.dbos
174
233
  .enqueue(
@@ -179,8 +238,9 @@ export class SanomaClient {
179
238
  applicationName: this.config.appName,
180
239
  authenticatedUser: startedBy.id,
181
240
  authenticatedRoles: startedBy.groups ?? [],
182
- // Kept on the run's status row too, so a listing tells sandbox runs apart without its input.
183
- ...(sandbox === undefined ? {} : { attributes: { sandbox } }),
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 } }),
184
244
  },
185
245
  args,
186
246
  )
@@ -200,6 +260,221 @@ export class SanomaClient {
200
260
  return handle.workflowID;
201
261
  }
202
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
+
203
478
  /** The run's audit record, in order, from the ledger store. */
204
479
  async ledger(runId: string): Promise<LedgerRecord[]> {
205
480
  await this.mustExist(runId);
@@ -262,7 +537,9 @@ export class SanomaClient {
262
537
  approvalId?: string,
263
538
  options: { timeoutSeconds?: number } = {},
264
539
  ): Promise<ApprovalState> {
265
- const msg = parseOrThrow(ApprovalMessage, message, "Not a decision");
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) };
266
543
  // A run that does not exist has no approvals, so run_not_found never hides already_decided.
267
544
  const [all, row] = await Promise.all([this.approvals(runId), this.mustExist(runId)]);
268
545
  const target = approvalId ? all.find((a) => a.id === approvalId) : all.find((a) => a.status === "pending");
@@ -283,11 +560,18 @@ export class SanomaClient {
283
560
  { runId, ...(approvalId ? { approvalId } : {}) },
284
561
  );
285
562
  }
286
- if (!mayDecide(target, msg.by)) {
287
- throw new SanomaError("not_approver", `${notApprover(target, msg.by)} (${target.id})`, {
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})`, {
288
571
  runId,
289
572
  approvalId: target.id,
290
573
  approver: target.approver,
574
+ reason,
291
575
  });
292
576
  }
293
577
  // The id names this message, so the run's answer says whether it decided with it, and is
@@ -315,6 +599,7 @@ export class SanomaClient {
315
599
  runId,
316
600
  approvalId: target.id,
317
601
  approver: target.approver,
602
+ reason: refused.reason,
318
603
  });
319
604
  }
320
605
  if (now.status !== "pending" && now.decidedWith !== id) throw alreadyDecided(runId, now);
@@ -392,12 +677,13 @@ export class SanomaClient {
392
677
  * loads it into its environment, unless its environment sets the variable itself. Refuses
393
678
  * (`invalid_input`) a name no driver declares, and a value the variable's schema refuses, with
394
679
  * the schema's reason and never the value; an empty value too (`clearCredential` unsets one).
395
- * Nothing records the value but its row, which records `by` and when.
680
+ * Nothing records the value but its row, which records `by` and when. `by` is admitted as
681
+ * `start`'s `startedBy` is (`not_admitted`).
396
682
  */
397
683
  async setCredential(name: string, value: string, options: { by: string }): Promise<void> {
684
+ const setBy = await this.admittedBy(options);
398
685
  // Checked against every declaration of the name, as the worker checks it.
399
686
  const statuses = this.declared(name, (n) => (n === name ? value : undefined));
400
- const setBy = actorOf(options);
401
687
  const refused = statuses.find((c) => c.status !== "set");
402
688
  if (refused) {
403
689
  const problem = refused.problem ?? "it is empty; clear it to unset it";
@@ -412,11 +698,11 @@ export class SanomaClient {
412
698
  /**
413
699
  * Deletes the stored value of a declared variable, and tells the workers, which unset it unless
414
700
  * their environment sets it. `by` is who asks; nothing records it once the row is gone.
415
- * Refuses (`invalid_input`) a name no driver declares.
701
+ * Refuses (`invalid_input`) a name no driver declares, and `by` as `setCredential` does.
416
702
  */
417
703
  async clearCredential(name: string, options: { by: string }): Promise<void> {
704
+ await this.admittedBy(options);
418
705
  this.declared(name, () => undefined);
419
- actorOf(options);
420
706
  await deleteCredential(await this.credentialsDb(), name);
421
707
  }
422
708
 
@@ -430,7 +716,7 @@ export class SanomaClient {
430
716
  if ([...credentialsOf(this.drivers, () => undefined).values()].flat().some((c) => c.name === name)) {
431
717
  return this.setCredential(name, value, options);
432
718
  }
433
- const setBy = actorOf(options);
719
+ const setBy = await this.admittedBy(options);
434
720
  const problem = !VARIABLE.test(name) ? "That is not a variable name" : !value ? "Enter a value" : undefined;
435
721
  if (problem) {
436
722
  const path = VARIABLE.test(name) ? "value" : "name";
@@ -478,7 +764,12 @@ export class SanomaClient {
478
764
  }
479
765
 
480
766
  async close() {
481
- await Promise.all([this.dbos.destroy(), this.credentialsPool.close(), this.audit.close()]);
767
+ await Promise.all([
768
+ this.dbos.destroy(),
769
+ this.credentialsPool.close(),
770
+ this.audit.close(),
771
+ this.config.dataStore?.close(),
772
+ ]);
482
773
  }
483
774
 
484
775
  /**
@@ -506,11 +797,28 @@ export class SanomaClient {
506
797
  return this.credentialsPool.db();
507
798
  }
508
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
+
509
814
  /** Refuses a run id whose run, as stored, was started with another input, by someone else, or in another sandbox. */
510
815
  private async mustMatch(runId: string, args: RunArgs) {
511
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.
512
818
  const differs = (["input", "startedBy", "sandbox"] as const).filter(
513
- (key) => !stored || !sameJson(stored[key], args[key]),
819
+ (key) =>
820
+ !stored ||
821
+ (key === "startedBy" ? stored.startedBy?.id !== args.startedBy.id : !sameJson(stored[key], args[key])),
514
822
  );
515
823
  if (differs.length) {
516
824
  throw new SanomaError(
@@ -521,9 +829,15 @@ export class SanomaClient {
521
829
  }
522
830
  }
523
831
 
524
- /** The run's status row, read without its input (and its output, unless asked), or `run_not_found`. */
525
- private async mustExist(runId: string, { loadOutput = false } = {}) {
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 } = {}) {
526
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);
527
841
  if (!row) throw new SanomaError("run_not_found", `No run ${runId}`, { runId });
528
842
  return row;
529
843
  }
@@ -559,11 +873,18 @@ export class SanomaClient {
559
873
  }): Promise<RunSummary> {
560
874
  const approvals = await this.approvals(r.workflowID);
561
875
  const groups = r.authenticatedRoles ?? [];
876
+ const onBehalfOf = r.attributes?.onBehalfOf;
562
877
  return {
563
878
  runId: r.workflowID,
564
879
  workflow: r.workflowName,
565
880
  status: runStatus(r.status, approvals),
566
- startedBy: r.authenticatedUser ? { id: r.authenticatedUser, ...(groups.length ? { groups } : {}) } : undefined,
881
+ startedBy: r.authenticatedUser
882
+ ? {
883
+ id: r.authenticatedUser,
884
+ ...(groups.length ? { groups } : {}),
885
+ ...(typeof onBehalfOf === "string" && { onBehalfOf }),
886
+ }
887
+ : undefined,
567
888
  createdAt: r.createdAt,
568
889
  updatedAt: r.updatedAt,
569
890
  approvals,
@@ -573,6 +894,20 @@ export class SanomaClient {
573
894
  }
574
895
  }
575
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
+
576
911
  /** The value as JSON reads it back: a Date as its string, undefined members gone. */
577
912
  const asJson = (v: unknown): unknown => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
578
913
 
@@ -591,10 +926,6 @@ export interface StoredSecret {
591
926
  /** What an environment variable's name looks like. */
592
927
  const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
593
928
 
594
- /** `options.by`, who sets or clears a credential: a name, or `invalid_input`. */
595
- const actorOf = (options: { by: string }): string =>
596
- parseOrThrow(z.string().min(1), options?.by, '`by` must name who is asking, such as "alice"');
597
-
598
929
  const alreadyDecided = (runId: string, a: ApprovalState) =>
599
930
  new SanomaError("already_decided", `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
600
931
  runId,