@sanoma-ai/workflows 0.5.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.
- package/README.md +49 -15
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +47 -5
- package/dist/config.js +71 -7
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +31 -1
- package/dist/db.js +89 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +88 -0
- package/dist/deadlines.js +171 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +6 -3
- package/dist/describe.js +11 -7
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +8 -0
- package/dist/fake.js +11 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +13 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +14 -2
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +10 -1
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +38 -2
- package/dist/shared.js +50 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +38 -1
- package/dist/sites.js +153 -26
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +61 -2
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +38 -0
- package/dist/supervisor.js +77 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +421 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup.d.ts +84 -0
- package/dist/wakeup.js +87 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +130 -39
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +8 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +110 -13
- package/src/database.ts +152 -0
- package/src/db.ts +108 -9
- package/src/deadlines.ts +234 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +19 -7
- package/src/errors.ts +14 -0
- package/src/fake.ts +12 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +13 -4
- package/src/index.ts +62 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +1 -1
- package/src/op.ts +15 -2
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +12 -1
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +62 -4
- package/src/sites.ts +198 -33
- package/src/source.ts +58 -3
- package/src/supervisor.ts +107 -0
- package/src/version.ts +3 -1
- package/src/wakeup-service.ts +511 -0
- package/src/wakeup.ts +150 -0
- 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 {
|
|
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
|
|
169
|
-
private readonly
|
|
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(
|
|
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.
|
|
179
|
-
this.storedSecrets = storedSecrets(() => this.
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
808
|
-
private
|
|
809
|
-
return this.
|
|
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
|
-
:
|
|
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(
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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 });
|