@sanoma-ai/workflows 0.6.0 → 0.7.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 (149) hide show
  1. package/README.md +48 -14
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +47 -5
  23. package/dist/config.js +71 -7
  24. package/dist/config.js.map +1 -1
  25. package/dist/database.d.ts +83 -0
  26. package/dist/database.js +92 -0
  27. package/dist/database.js.map +1 -0
  28. package/dist/db.d.ts +31 -1
  29. package/dist/db.js +89 -7
  30. package/dist/db.js.map +1 -1
  31. package/dist/deadlines.d.ts +88 -0
  32. package/dist/deadlines.js +171 -0
  33. package/dist/deadlines.js.map +1 -0
  34. package/dist/define.d.ts +25 -4
  35. package/dist/define.js +40 -0
  36. package/dist/define.js.map +1 -1
  37. package/dist/demo.d.ts +28 -0
  38. package/dist/demo.js +72 -0
  39. package/dist/demo.js.map +1 -0
  40. package/dist/describe.d.ts +5 -2
  41. package/dist/describe.js +10 -6
  42. package/dist/describe.js.map +1 -1
  43. package/dist/errors.d.ts +10 -1
  44. package/dist/errors.js +13 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/fake.d.ts +8 -0
  47. package/dist/fake.js +11 -1
  48. package/dist/fake.js.map +1 -1
  49. package/dist/grid.d.ts +34 -0
  50. package/dist/grid.js +62 -0
  51. package/dist/grid.js.map +1 -0
  52. package/dist/host.d.ts +13 -2
  53. package/dist/host.js +2 -2
  54. package/dist/host.js.map +1 -1
  55. package/dist/index.d.ts +16 -8
  56. package/dist/index.js +12 -5
  57. package/dist/index.js.map +1 -1
  58. package/dist/ledger.d.ts +11 -2
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/lint.js +1 -1
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +14 -2
  63. package/dist/op.js.map +1 -1
  64. package/dist/policy.d.ts +7 -1
  65. package/dist/policy.js +2 -1
  66. package/dist/policy.js.map +1 -1
  67. package/dist/resource.d.ts +13 -1
  68. package/dist/resource.js +9 -1
  69. package/dist/resource.js.map +1 -1
  70. package/dist/resources.d.ts +2 -0
  71. package/dist/resources.js +10 -1
  72. package/dist/resources.js.map +1 -1
  73. package/dist/run.d.ts +5 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/sandbox.d.ts +38 -10
  76. package/dist/sandbox.js +66 -35
  77. package/dist/sandbox.js.map +1 -1
  78. package/dist/scenario.d.ts +18 -1
  79. package/dist/scenario.js +77 -27
  80. package/dist/scenario.js.map +1 -1
  81. package/dist/scenarios.d.ts +10 -1
  82. package/dist/scenarios.js +19 -4
  83. package/dist/scenarios.js.map +1 -1
  84. package/dist/schedules.d.ts +66 -0
  85. package/dist/schedules.js +158 -0
  86. package/dist/schedules.js.map +1 -0
  87. package/dist/schema.d.ts +6 -0
  88. package/dist/schema.js +13 -3
  89. package/dist/schema.js.map +1 -1
  90. package/dist/shared.d.ts +30 -2
  91. package/dist/shared.js +42 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/source.d.ts +61 -2
  94. package/dist/source.js.map +1 -1
  95. package/dist/supervisor.d.ts +38 -0
  96. package/dist/supervisor.js +77 -0
  97. package/dist/supervisor.js.map +1 -0
  98. package/dist/version.d.ts +3 -1
  99. package/dist/version.js +3 -1
  100. package/dist/version.js.map +1 -1
  101. package/dist/wakeup-service.d.ts +83 -0
  102. package/dist/wakeup-service.js +421 -0
  103. package/dist/wakeup-service.js.map +1 -0
  104. package/dist/wakeup.d.ts +84 -0
  105. package/dist/wakeup.js +87 -0
  106. package/dist/wakeup.js.map +1 -0
  107. package/dist/worker.d.ts +39 -1
  108. package/dist/worker.js +130 -39
  109. package/dist/worker.js.map +1 -1
  110. package/oxlint.json +2 -1
  111. package/package.json +8 -2
  112. package/src/apply.ts +25 -6
  113. package/src/approvals.ts +89 -16
  114. package/src/call.ts +40 -29
  115. package/src/cedar.ts +24 -6
  116. package/src/change.ts +222 -0
  117. package/src/chats.ts +114 -0
  118. package/src/client.ts +145 -55
  119. package/src/config.ts +110 -13
  120. package/src/database.ts +152 -0
  121. package/src/db.ts +108 -9
  122. package/src/deadlines.ts +234 -0
  123. package/src/define.ts +71 -4
  124. package/src/demo.ts +87 -0
  125. package/src/describe.ts +18 -6
  126. package/src/errors.ts +14 -0
  127. package/src/fake.ts +12 -0
  128. package/src/grid.ts +100 -0
  129. package/src/host.ts +13 -4
  130. package/src/index.ts +61 -3
  131. package/src/ledger.ts +15 -3
  132. package/src/lint.ts +1 -1
  133. package/src/op.ts +15 -2
  134. package/src/policy.ts +7 -1
  135. package/src/resource.ts +22 -3
  136. package/src/resources.ts +12 -1
  137. package/src/run.ts +5 -0
  138. package/src/sandbox.ts +97 -37
  139. package/src/scenario.ts +97 -28
  140. package/src/scenarios.ts +21 -5
  141. package/src/schedules.ts +206 -0
  142. package/src/schema.ts +16 -3
  143. package/src/shared.ts +51 -4
  144. package/src/source.ts +58 -3
  145. package/src/supervisor.ts +107 -0
  146. package/src/version.ts +3 -1
  147. package/src/wakeup-service.ts +511 -0
  148. package/src/wakeup.ts +150 -0
  149. package/src/worker.ts +181 -44
package/src/chats.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { UIMessage } from "ai";
2
+ import type { Pool } from "pg";
3
+ import { CHAT_MESSAGES_TABLE as MESSAGES, CHATS_TABLE as CHATS } from "./db.ts";
4
+ import { SanomaError } from "./errors.ts";
5
+
6
+ // The operator agent's chats, in the config's database beside the runtime's other tables: one row
7
+ // per chat (whose it is, the engine it runs on, the draft it edits) and its messages as the AI
8
+ // SDK's UI messages. The turn itself is not durable: what a restart keeps is this transcript and
9
+ // the draft's branch, and the person sends "continue".
10
+
11
+ /** A chat as `SanomaClient.chats.list` reads it. */
12
+ export interface Chat {
13
+ id: string;
14
+ /** Whose chat it is: the principal's id. Nobody else reads or continues it. */
15
+ person: string;
16
+ /** What runs its turns: `"loop"`, the app's own loop, or another engine the app knows. */
17
+ engine: string;
18
+ /** The draft it edits (`Source.draft`), once its first edit opened one. */
19
+ draftId?: string;
20
+ title: string;
21
+ /** When it was opened, in milliseconds since the epoch. */
22
+ createdAt: number;
23
+ }
24
+
25
+ /** The chats' store, `SanomaClient.chats`. */
26
+ export interface Chats {
27
+ /**
28
+ * Opens the chat `id` (the browser's) for `person` on `engine`, titled `title`. An id another
29
+ * chat has is refused (`invalid_input`).
30
+ */
31
+ open(chat: { id: string; person: string; engine: string; title: string }): Promise<void>;
32
+ /** Records the draft the chat edits. */
33
+ attach(id: string, draftId: string): Promise<void>;
34
+ /**
35
+ * Writes these messages of the chat, each by its id: a message already there is replaced in its
36
+ * place, a new one goes after the rest, in the order given.
37
+ */
38
+ upsert(id: string, messages: readonly UIMessage[]): Promise<void>;
39
+ /** The chat's messages in order; none for a chat that has none. */
40
+ load(id: string): Promise<UIMessage[]>;
41
+ /** The chat `id`, or undefined when there is none. */
42
+ get(id: string): Promise<Chat | undefined>;
43
+ /** The person's chats, the newest first: the latest 50. */
44
+ list(person: string): Promise<Chat[]>;
45
+ }
46
+
47
+ /** A row of the chats' table. */
48
+ interface ChatRow {
49
+ id: string;
50
+ person: string;
51
+ engine: string;
52
+ draft_id: string | null;
53
+ title: string;
54
+ created_at: Date;
55
+ }
56
+
57
+ const COLUMNS = "id, person, engine, draft_id, title, created_at";
58
+
59
+ const chatOf = ({ draft_id, created_at, ...row }: ChatRow): Chat => ({
60
+ ...row,
61
+ ...(draft_id === null ? {} : { draftId: draft_id }),
62
+ createdAt: created_at.getTime(),
63
+ });
64
+
65
+ /** The chats' store on the pool `db` gives, the config's database once it is prepared. */
66
+ export function chatStore(db: () => Promise<Pool>): Chats {
67
+ return {
68
+ async open({ id, person, engine, title }) {
69
+ const { rows } = await (
70
+ await db()
71
+ ).query(
72
+ `INSERT INTO ${CHATS} (id, person, engine, title) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING RETURNING id`,
73
+ [id, person, engine, title],
74
+ );
75
+ if (!rows.length) throw new SanomaError("invalid_input", `Chat ${id} exists already; open it with another id`);
76
+ },
77
+ async attach(id, draftId) {
78
+ await (await db()).query(`UPDATE ${CHATS} SET draft_id = $2 WHERE id = $1`, [id, draftId]);
79
+ },
80
+ async upsert(id, messages) {
81
+ if (!messages.length) return;
82
+ if (messages.some((m) => !m.id)) throw new SanomaError("invalid_input", "A chat message needs an id");
83
+ // A new message's place is after the chat's last, in the order given.
84
+ await (
85
+ await db()
86
+ ).query(
87
+ `INSERT INTO ${MESSAGES} (chat_id, id, seq, message) ` +
88
+ `SELECT $1, m.value->>'id', last.seq + m.n, m.value ` +
89
+ `FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY AS m (value, n), ` +
90
+ `(SELECT coalesce(max(seq), 0) AS seq FROM ${MESSAGES} WHERE chat_id = $1) AS last ` +
91
+ "ON CONFLICT (chat_id, id) DO UPDATE SET message = EXCLUDED.message",
92
+ [id, JSON.stringify(messages)],
93
+ );
94
+ },
95
+ async load(id) {
96
+ const { rows } = await (
97
+ await db()
98
+ ).query<{ message: UIMessage }>(`SELECT message FROM ${MESSAGES} WHERE chat_id = $1 ORDER BY seq, id`, [id]);
99
+ return rows.map((row) => row.message);
100
+ },
101
+ async get(id) {
102
+ const { rows } = await (await db()).query<ChatRow>(`SELECT ${COLUMNS} FROM ${CHATS} WHERE id = $1`, [id]);
103
+ return rows[0] && chatOf(rows[0]);
104
+ },
105
+ async list(person) {
106
+ const { rows } = await (
107
+ await db()
108
+ ).query<ChatRow>(`SELECT ${COLUMNS} FROM ${CHATS} WHERE person = $1 ORDER BY created_at DESC, id LIMIT 50`, [
109
+ person,
110
+ ]);
111
+ return rows.map(chatOf);
112
+ },
113
+ };
114
+ }
package/src/client.ts CHANGED
@@ -3,8 +3,17 @@ import { isDeepStrictEqual } from "node:util";
3
3
  import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
4
4
  import type { Pool } from "pg";
5
5
  import { z } from "zod";
6
- import { APPROVALS_EVENT, ApprovalMessage, decisionEventOf, messageKeyOf, topicOf } from "./approvals.ts";
6
+ import {
7
+ APPROVALS_EVENT,
8
+ ApprovalMessage,
9
+ ApprovalWithdrawal,
10
+ decisionEventOf,
11
+ messageKeyOf,
12
+ topicOf,
13
+ withdrawalRefusal,
14
+ } from "./approvals.ts";
7
15
  import { beforeOf, decideCall, decideStart, recorded, startHoldOf } from "./call.ts";
16
+ import { type Chats, chatStore } from "./chats.ts";
8
17
  import { collectionsDriver } from "./collection.ts";
9
18
  import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
10
19
  import { deleteCredential, storeCredential, storedCredentials, storedSecrets } from "./credentials.ts";
@@ -39,6 +48,7 @@ import {
39
48
  isVisitor,
40
49
  refusalOf,
41
50
  type RunStatus,
51
+ SCHEDULE_AGENT,
42
52
  WORKFLOW_ACTION,
43
53
  } from "./shared.ts";
44
54
 
@@ -158,6 +168,22 @@ export interface CallOptions {
158
168
  id?: string;
159
169
  }
160
170
 
171
+ /** What `SanomaClient.connect` is given besides the config. */
172
+ export interface ClientOptions {
173
+ /**
174
+ * Called, never awaited, right before each change a worker must run: a run's start (drift,
175
+ * apply and a held call are starts) and an approval's decision or withdrawal; never for a read. A process that
176
+ * runs a worker that stops when idle starts it again with it (`Supervisor.resume`). Must not throw.
177
+ */
178
+ onWrite?: () => void;
179
+ /**
180
+ * The config as the caller resolved it (`resolveConfig(config, options)`), for a client that
181
+ * shares its drivers with the caller, as `startWorker`'s `resolved` does. The caller closes its
182
+ * `dataStore`.
183
+ */
184
+ resolved?: ResolvedConfig;
185
+ }
186
+
161
187
  /** Talks to the runtime from another process (the app, a script), through the shared Postgres. */
162
188
  export class SanomaClient {
163
189
  private readonly dbos: DBOSClient;
@@ -165,18 +191,28 @@ export class SanomaClient {
165
191
  private readonly drivers: ReadonlyMap<string, Driver>;
166
192
  /** The config's own ledger store, or a Postgres one made for this client, which `close` then closes. */
167
193
  private readonly audit: OwnedLedger;
168
- /** On the config's database, for the credentials table: opened on first use. */
169
- private readonly credentialsPool: LazyPool;
194
+ /** On the config's database, for the runtime's tables (the credentials, the chats): opened on first use. */
195
+ private readonly pool: LazyPool;
170
196
  /** What a direct call's driver reads secrets from, as a worker's live driver does. */
171
197
  private readonly storedSecrets: Secrets;
198
+ /** The operator agent's chats: their rows and their transcripts, in the config's database. */
199
+ readonly chats: Chats;
200
+ private readonly options: ClientOptions;
172
201
 
173
- private constructor(dbos: DBOSClient, config: ResolvedConfig, drivers: ReadonlyMap<string, Driver>) {
202
+ private constructor(
203
+ dbos: DBOSClient,
204
+ config: ResolvedConfig,
205
+ drivers: ReadonlyMap<string, Driver>,
206
+ options: ClientOptions,
207
+ ) {
174
208
  this.dbos = dbos;
175
209
  this.config = config;
176
210
  this.drivers = drivers;
211
+ this.options = options;
177
212
  this.audit = ownedLedger(config);
178
- this.credentialsPool = lazyPool(config.databaseUrl, "credentials");
179
- this.storedSecrets = storedSecrets(() => this.credentialsDb(), "sanoma");
213
+ this.pool = lazyPool(config.databaseUrl, "client");
214
+ this.storedSecrets = storedSecrets(() => this.db(), "sanoma");
215
+ this.chats = chatStore(() => this.db());
180
216
  }
181
217
 
182
218
  /**
@@ -184,10 +220,10 @@ export class SanomaClient {
184
220
  * what the worker would refuse. Reads ledgers from the config's ledger store, or from the
185
221
  * Postgres store on its database when it has none.
186
222
  */
187
- static async connect(config: SanomaConfig): Promise<SanomaClient> {
188
- const resolved = resolveConfig(config);
223
+ static async connect(config: SanomaConfig, options: ClientOptions = {}): Promise<SanomaClient> {
224
+ const resolved = options.resolved ?? resolveConfig(config);
189
225
  const dbos = await dbosClient(resolved.databaseUrl, resolved.appName);
190
- return new SanomaClient(dbos, resolved, resolved.driversByVendor);
226
+ return new SanomaClient(dbos, resolved, resolved.driversByVendor, options);
191
227
  }
192
228
 
193
229
  /**
@@ -230,6 +266,7 @@ export class SanomaClient {
230
266
  const hold = await this.startHold(workflow.name, parsed, startedBy, runId);
231
267
  const args: RunArgs = { input, startedBy, ...(sandbox === undefined ? {} : { sandbox }), ...(hold && { hold }) };
232
268
  const { onBehalfOf } = startedBy;
269
+ this.options.onWrite?.();
233
270
  // An id DBOS has already returns that run, unless it is another workflow's, which DBOS refuses.
234
271
  const handle = await this.dbos
235
272
  .enqueue(
@@ -552,6 +589,53 @@ export class SanomaClient {
552
589
  const given = parseOrThrow(ApprovalMessage, message, "Not a decision");
553
590
  const declared = readPeople(this.config.root);
554
591
  const msg = { ...given, by: admit(given.by, declared) };
592
+ const { target, row } = await this.pendingApproval(runId, approvalId);
593
+ const reason = refusalOf(
594
+ target,
595
+ msg.by,
596
+ { actor: row.authenticatedUser ? { id: row.authenticatedUser } : undefined },
597
+ declared.people,
598
+ );
599
+ if (reason !== undefined) throw notApprover(runId, target, reason);
600
+ // The id names this message, so the run's answer says whether it decided with it, and is
601
+ // in its idempotency key, so a retried send queues it once.
602
+ const id = msg.id ?? randomUUID();
603
+ return this.sendToApproval(runId, target, { ...msg, id }, options.timeoutSeconds);
604
+ }
605
+
606
+ /**
607
+ * Withdraws the run's pending approval `approvalId`, saying `reason`, as `options.by`: the run's
608
+ * starter, or someone who may decide it (`not_approver` otherwise, here and again in the run,
609
+ * which records who withdrew it). The run reads the
610
+ * withdrawal as a decision, and the step that waits on it throws `approval_withdrawn`. Returns
611
+ * the approval as withdrawn, and refuses as `decide` does: `already_decided` when it is not
612
+ * pending (or another decision reached the run first), `run_ended`, `no_pending_approval`,
613
+ * `run_not_found`; and returns it still `pending` when the run does not read the withdrawal
614
+ * within `timeoutSeconds` (30 by default).
615
+ */
616
+ async withdraw(
617
+ runId: string,
618
+ approvalId: string,
619
+ reason: string,
620
+ options: { by: Principal | string; timeoutSeconds?: number },
621
+ ): Promise<ApprovalState> {
622
+ const withdraw = parseOrThrow(ApprovalWithdrawal.shape.withdraw, reason, "A withdrawal needs a reason");
623
+ const by = await this.admitted(
624
+ parseOrThrow(PrincipalOrId, options?.by, '`by` must be a principal, such as "alice" or { id: "alice" }'),
625
+ );
626
+ const { target, row } = await this.pendingApproval(runId, approvalId);
627
+ const refusal = withdrawalRefusal(target, by, row.authenticatedUser);
628
+ if (refusal !== undefined) throw notApprover(runId, target, refusal);
629
+ const id = randomUUID();
630
+ return this.sendToApproval(runId, target, { withdraw, by, id }, options.timeoutSeconds);
631
+ }
632
+
633
+ /**
634
+ * The run's approval `approvalId` (else its first pending one) and the run's row, when the
635
+ * approval is pending on a run that has not ended: `already_decided`, `run_ended`,
636
+ * `no_pending_approval` or `run_not_found` otherwise.
637
+ */
638
+ private async pendingApproval(runId: string, approvalId: string | undefined) {
555
639
  // A run that does not exist has no approvals, so run_not_found never hides already_decided.
556
640
  const [all, row] = await Promise.all([this.approvals(runId), this.mustExist(runId)]);
557
641
  const target = approvalId ? all.find((a) => a.id === approvalId) : all.find((a) => a.status === "pending");
@@ -572,28 +656,26 @@ export class SanomaClient {
572
656
  { runId, ...(approvalId ? { approvalId } : {}) },
573
657
  );
574
658
  }
575
- const reason = refusalOf(
576
- target,
577
- msg.by,
578
- { actor: row.authenticatedUser ? { id: row.authenticatedUser } : undefined },
579
- declared.people,
580
- );
581
- if (reason !== undefined) {
582
- throw new SanomaError("not_approver", `${reason} (${target.id})`, {
583
- runId,
584
- approvalId: target.id,
585
- approver: target.approver,
586
- reason,
587
- });
588
- }
589
- // The id names this message, so the run's answer says whether it decided with it, and is
590
- // in its idempotency key, so a retried send queues it once.
591
- const id = msg.id ?? randomUUID();
592
- await this.dbos.send(runId, { ...msg, id }, topicOf(target.id), messageKeyOf(target.id, id));
659
+ return { target, row };
660
+ }
661
+
662
+ /**
663
+ * Sends a message with an `id` to the approval and waits up to `timeoutSeconds` (30 by
664
+ * default) for the run to read it: the approval as the run then has it, still `pending` if it
665
+ * has not read it. `not_approver` when the run refused it, `already_decided` when another
666
+ * message decided it first.
667
+ */
668
+ private async sendToApproval(
669
+ runId: string,
670
+ target: ApprovalState,
671
+ message: (ApprovalMessage | ApprovalWithdrawal) & { id: string },
672
+ timeoutSeconds = 30,
673
+ ): Promise<ApprovalState> {
674
+ this.options.onWrite?.();
675
+ await this.dbos.send(runId, message, topicOf(target.id), messageKeyOf(target.id, message.id));
593
676
  // DBOSClient does not LISTEN for events: it polls, every 10 s unless told otherwise. A
594
677
  // running worker answers within moments, so poll often at first, then once a second.
595
678
  const event = decisionEventOf(target.id);
596
- const timeoutSeconds = options.timeoutSeconds ?? 30;
597
679
  const soon = Math.min(2, timeoutSeconds);
598
680
  const decided =
599
681
  (await this.dbos.getEvent<ApprovalState>(runId, event, { timeoutSeconds: soon, pollingIntervalMs: 100 })) ??
@@ -605,16 +687,10 @@ export class SanomaClient {
605
687
  : null);
606
688
  const now = decided ?? (await this.approvals(runId)).find((a) => a.id === target.id) ?? target;
607
689
  // Read, but refused: the run checks the sender again, and said no.
608
- const refused = now.refused.find((r) => r.id === id);
609
- if (refused) {
610
- throw new SanomaError("not_approver", `${refused.reason} (${target.id})`, {
611
- runId,
612
- approvalId: target.id,
613
- approver: target.approver,
614
- reason: refused.reason,
615
- });
616
- }
617
- if (now.status !== "pending" && now.decidedWith !== id) throw alreadyDecided(runId, now);
690
+ const refused = now.refused.find((r) => r.id === message.id);
691
+ if (refused) throw notApprover(runId, target, refused.reason);
692
+ // Another message reached the run first.
693
+ if (now.status !== "pending" && now.decidedWith !== message.id) throw alreadyDecided(runId, now);
618
694
  return now;
619
695
  }
620
696
 
@@ -704,7 +780,7 @@ export class SanomaClient {
704
780
  issues: [{ path: ["value"], message: problem, code: "custom" }],
705
781
  });
706
782
  }
707
- await storeCredential(await this.credentialsDb(), { name, value, setBy });
783
+ await storeCredential(await this.db(), { name, value, setBy });
708
784
  }
709
785
 
710
786
  /**
@@ -715,7 +791,7 @@ export class SanomaClient {
715
791
  async clearCredential(name: string, options: { by: string }): Promise<void> {
716
792
  await this.admittedBy(options);
717
793
  this.declared(name, () => undefined);
718
- await deleteCredential(await this.credentialsDb(), name);
794
+ await deleteCredential(await this.db(), name);
719
795
  }
720
796
 
721
797
  /**
@@ -734,7 +810,7 @@ export class SanomaClient {
734
810
  const path = VARIABLE.test(name) ? "value" : "name";
735
811
  throw new SanomaError("invalid_input", problem, { issues: [{ path: [path], message: problem, code: "custom" }] });
736
812
  }
737
- await storeCredential(await this.credentialsDb(), { name, value, setBy });
813
+ await storeCredential(await this.db(), { name, value, setBy });
738
814
  }
739
815
 
740
816
  /**
@@ -743,7 +819,7 @@ export class SanomaClient {
743
819
  * is left out. For server code: a page is told whether one is set, never its value.
744
820
  */
745
821
  async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
746
- const rows = await storedCredentials(await this.credentialsDb(), names);
822
+ const rows = await storedCredentials(await this.db(), names);
747
823
  const found = new Map<string, StoredSecret>();
748
824
  for (const name of names) {
749
825
  const env = process.env[name];
@@ -763,7 +839,7 @@ export class SanomaClient {
763
839
  async credentials(): Promise<Map<string, CredentialStatus[]>> {
764
840
  const names = [...credentialsOf(this.drivers, () => undefined).values()].flat().map((c) => c.name);
765
841
  if (!names.length) return new Map();
766
- const rows = await storedCredentials(await this.credentialsDb(), names);
842
+ const rows = await storedCredentials(await this.db(), names);
767
843
  const statuses = credentialsOf(this.drivers, (name) => process.env[name] || rows.get(name)?.value);
768
844
  const sourced = (c: CredentialStatus): CredentialStatus => {
769
845
  const row = rows.get(c.name);
@@ -778,9 +854,9 @@ export class SanomaClient {
778
854
  async close() {
779
855
  await Promise.all([
780
856
  this.dbos.destroy(),
781
- this.credentialsPool.close(),
857
+ this.pool.close(),
782
858
  this.audit.close(),
783
- this.config.dataStore?.close(),
859
+ this.options.resolved ? undefined : this.config.dataStore?.close(),
784
860
  ]);
785
861
  }
786
862
 
@@ -804,9 +880,9 @@ export class SanomaClient {
804
880
  });
805
881
  }
806
882
 
807
- /** The pool the credentials are read and written with, on the database once it is prepared. */
808
- private credentialsDb(): Promise<Pool> {
809
- return this.credentialsPool.db();
883
+ /** The pool the runtime's tables are read and written with, on the database once it is prepared. */
884
+ private db(): Promise<Pool> {
885
+ return this.pool.db();
810
886
  }
811
887
 
812
888
  /**
@@ -883,6 +959,7 @@ export class SanomaClient {
883
959
  authenticatedRoles?: string[];
884
960
  error?: unknown;
885
961
  attributes?: Record<string, unknown>;
962
+ scheduleName?: string;
886
963
  }): Promise<RunSummary> {
887
964
  const approvals = await this.approvals(r.workflowID);
888
965
  const groups = r.authenticatedRoles ?? [];
@@ -897,7 +974,10 @@ export class SanomaClient {
897
974
  ...(groups.length ? { groups } : {}),
898
975
  ...(typeof onBehalfOf === "string" && { onBehalfOf }),
899
976
  }
900
- : undefined,
977
+ : // DBOS enqueues a schedule's tick as nobody; the run starts as the schedule's agent.
978
+ r.scheduleName
979
+ ? { id: SCHEDULE_AGENT }
980
+ : undefined,
901
981
  createdAt: r.createdAt,
902
982
  updatedAt: r.updatedAt,
903
983
  approvals,
@@ -940,9 +1020,19 @@ export interface StoredSecret {
940
1020
  const VARIABLE = /^[A-Z_][A-Z0-9_]*$/;
941
1021
 
942
1022
  const alreadyDecided = (runId: string, a: ApprovalState) =>
943
- new SanomaError("already_decided", `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`, {
944
- runId,
945
- approvalId: a.id,
946
- status: a.status,
947
- decidedBy: a.decidedBy,
948
- });
1023
+ new SanomaError(
1024
+ "already_decided",
1025
+ a.status === "withdrawn"
1026
+ ? `${a.id} on run ${runId} was already withdrawn: ${a.withdrawnReason}`
1027
+ : `${a.id} on run ${runId} was already ${a.status} by ${a.decidedBy}`,
1028
+ {
1029
+ runId,
1030
+ approvalId: a.id,
1031
+ status: a.status,
1032
+ ...(a.decidedBy === undefined ? {} : { decidedBy: a.decidedBy }),
1033
+ ...(a.withdrawnReason === undefined ? {} : { withdrawnReason: a.withdrawnReason }),
1034
+ },
1035
+ );
1036
+
1037
+ const notApprover = (runId: string, a: ApprovalState, reason: string) =>
1038
+ new SanomaError("not_approver", `${reason} (${a.id})`, { runId, approvalId: a.id, approver: a.approver, reason });