@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.
- package/README.md +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
package/src/db.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
|
+
import { Client, escapeIdentifier, Pool } from "pg";
|
|
3
4
|
import { warn } from "./log.ts";
|
|
4
5
|
import { errorMessage } from "./shared.ts";
|
|
5
6
|
|
|
6
|
-
// The runtime's own tables live in the config's database, beside DBOS's: the credentials
|
|
7
|
-
// ledger. What they share is here: the one preparation of a
|
|
8
|
-
// is prepared and survives a dropped connection, and a table
|
|
7
|
+
// The runtime's own tables live in the config's database, beside DBOS's: the credentials, the
|
|
8
|
+
// ledger and the collections' records. What they share is here: the one preparation of a
|
|
9
|
+
// database, a pool that opens once it is prepared and survives a dropped connection, and a table
|
|
10
|
+
// creation two processes may race.
|
|
9
11
|
|
|
10
12
|
/** A connection or a pool: what the queries take. */
|
|
11
13
|
export type Db = Pick<Pool | Client, "query">;
|
|
@@ -14,6 +16,8 @@ export type Db = Pick<Pool | Client, "query">;
|
|
|
14
16
|
export const CREDENTIALS_TABLE = "sanoma_credentials";
|
|
15
17
|
/** Where the Postgres ledger keeps its records. */
|
|
16
18
|
export const LEDGER_TABLE = "sanoma_ledger";
|
|
19
|
+
/** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
|
|
20
|
+
export const DATA_TABLE = "sanoma_data";
|
|
17
21
|
|
|
18
22
|
/** The runtime's tables, each with the word the messages name it by. */
|
|
19
23
|
const TABLES: readonly [what: string, ddl: string][] = [
|
|
@@ -30,13 +34,23 @@ const TABLES: readonly [what: string, ddl: string][] = [
|
|
|
30
34
|
`run_id text NOT NULL, seq integer NOT NULL, record jsonb NOT NULL); ` +
|
|
31
35
|
`CREATE INDEX IF NOT EXISTS ${LEDGER_TABLE}_run_seq ON ${LEDGER_TABLE} (run_id, seq, n)`,
|
|
32
36
|
],
|
|
37
|
+
[
|
|
38
|
+
"data",
|
|
39
|
+
`CREATE TABLE IF NOT EXISTS ${DATA_TABLE} (collection text NOT NULL, key text NOT NULL, record jsonb NOT NULL, ` +
|
|
40
|
+
"updated_at timestamptz NOT NULL DEFAULT now(), updated_by text NOT NULL, PRIMARY KEY (collection, key))",
|
|
41
|
+
],
|
|
33
42
|
];
|
|
34
43
|
|
|
35
44
|
/** The Postgres error code of `err`, or an empty string when it has none. */
|
|
36
45
|
export const pgCode = (err: unknown): string => (err as { code?: unknown } | null)?.code?.toString() ?? "";
|
|
37
46
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Postgres's codes for a database, a row type or a table another process made first. Two racing
|
|
49
|
+
* `CREATE TABLE IF NOT EXISTS` both pass the check, and the loser fails on the table's row type in
|
|
50
|
+
* `pg_type`: as a unique violation (23505), or as a duplicate object (42710, "type ... already
|
|
51
|
+
* exists"). Two racing `CREATE DATABASE` fail as 42P04, or on `pg_database` as 23505.
|
|
52
|
+
*/
|
|
53
|
+
const MADE_ALREADY = new Set(["23505", "42P04", "42P07", "42710"]);
|
|
40
54
|
|
|
41
55
|
/** True for the failure of a create that another process won: what was to be made is there. */
|
|
42
56
|
export const madeAlready = (err: unknown): boolean => MADE_ALREADY.has(pgCode(err));
|
|
@@ -82,6 +96,9 @@ const IDLE_IN_TRANSACTION_TIMEOUT = "60s";
|
|
|
82
96
|
/** Postgres's code for a statement the role lacks the privilege for. */
|
|
83
97
|
const INSUFFICIENT_PRIVILEGE = "42501";
|
|
84
98
|
|
|
99
|
+
/** Postgres's code for a database that is not there (`invalid_catalog_name`). */
|
|
100
|
+
const NO_DATABASE = "3D000";
|
|
101
|
+
|
|
85
102
|
/** A pool opened on first use, once its database is prepared, and ended by `close`. */
|
|
86
103
|
export interface LazyPool {
|
|
87
104
|
db(): Promise<Pool>;
|
|
@@ -125,9 +142,9 @@ const prepared = new Map<string, Promise<void>>();
|
|
|
125
142
|
* Makes the database at `databaseUrl` ready for the runtime, once per process and URL: the
|
|
126
143
|
* database itself and DBOS's tables when they are not there yet (as a worker's launch makes
|
|
127
144
|
* them; credentials may be set, and a ledger read, before any worker has run), then the
|
|
128
|
-
* credentials and
|
|
129
|
-
* Two processes preparing one database both succeed
|
|
130
|
-
* preparation that fails is forgotten, so the next call tries again.
|
|
145
|
+
* credentials, ledger and data tables, and its idle-transaction timeout (`guardIdleTransactions`).
|
|
146
|
+
* Two processes preparing one database both succeed: the second waits for the first
|
|
147
|
+
* (`lockPreparation`). A preparation that fails is forgotten, so the next call tries again.
|
|
131
148
|
*/
|
|
132
149
|
export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
133
150
|
let preparing = prepared.get(databaseUrl);
|
|
@@ -141,33 +158,86 @@ export function prepareDatabase(databaseUrl: string): Promise<void> {
|
|
|
141
158
|
return preparing;
|
|
142
159
|
}
|
|
143
160
|
|
|
161
|
+
/** The error a preparation fails with, its cause `err`. */
|
|
162
|
+
const unprepared = (err: unknown): Error =>
|
|
163
|
+
new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
164
|
+
|
|
144
165
|
async function prepare(databaseUrl: string): Promise<void> {
|
|
166
|
+
const conn = await lockPreparation(databaseUrl).catch((err: unknown) => {
|
|
167
|
+
throw unprepared(err);
|
|
168
|
+
});
|
|
145
169
|
try {
|
|
146
|
-
await DBOS.migrate(databaseUrl)
|
|
170
|
+
await DBOS.migrate(databaseUrl).catch((err: unknown) => {
|
|
171
|
+
// A DBOS table made meanwhile by a release from before the lock, mid-upgrade: it is there.
|
|
172
|
+
if (!madeAlready(err)) throw unprepared(err);
|
|
173
|
+
});
|
|
174
|
+
for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
|
|
175
|
+
await guardIdleTransactions(conn);
|
|
176
|
+
} finally {
|
|
177
|
+
// Ending the session releases the lock; a failure to end it must not hide the preparation's.
|
|
178
|
+
await conn.end().catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A connection to the database at `databaseUrl` (made first when missing) holding the
|
|
184
|
+
* preparation's session lock until it ends, so a second process waits, then finds everything
|
|
185
|
+
* made. Racing `DBOS.migrate`s lose to each other in many ways (40P01, XX000), so the race is
|
|
186
|
+
* prevented rather than each loss forgiven. The lock is polled, never waited for by a blocked
|
|
187
|
+
* statement: `CREATE INDEX CONCURRENTLY` waits on that statement's snapshot, a deadlock Postgres
|
|
188
|
+
* cannot see (the lock's holder is this connection, not the migration's).
|
|
189
|
+
*/
|
|
190
|
+
async function lockPreparation(databaseUrl: string): Promise<Client> {
|
|
191
|
+
const conn = await connect(databaseUrl).catch(async (err: unknown) => {
|
|
192
|
+
if (pgCode(err) !== NO_DATABASE) throw err;
|
|
193
|
+
await createDatabase(databaseUrl);
|
|
194
|
+
return connect(databaseUrl);
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
const ask = "SELECT pg_try_advisory_lock(hashtext('sanoma.prepareDatabase')) AS locked";
|
|
198
|
+
while (!(await conn.query<{ locked: boolean }>(ask)).rows[0]!.locked) await delay(50);
|
|
199
|
+
return conn;
|
|
147
200
|
} catch (err) {
|
|
148
|
-
|
|
149
|
-
|
|
201
|
+
await conn.end().catch(() => undefined);
|
|
202
|
+
throw err;
|
|
150
203
|
}
|
|
151
|
-
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function connect(databaseUrl: string): Promise<Client> {
|
|
207
|
+
const conn = new Client({ connectionString: databaseUrl, connectionTimeoutMillis: 10_000 });
|
|
208
|
+
// An error nobody listens for ends the process; the query in flight fails with it.
|
|
209
|
+
conn.on("error", (err) => warn(`a preparation connection failed: ${errorMessage(err)}`));
|
|
152
210
|
await conn.connect();
|
|
211
|
+
return conn;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Makes the database at `databaseUrl` through its server's `postgres` database, as `DBOS.migrate`
|
|
216
|
+
* would. Another process making it first is no failure.
|
|
217
|
+
*/
|
|
218
|
+
async function createDatabase(databaseUrl: string): Promise<void> {
|
|
219
|
+
const url = new URL(databaseUrl);
|
|
220
|
+
const name = decodeURI(url.pathname.slice(1));
|
|
221
|
+
url.pathname = "/postgres";
|
|
222
|
+
const admin = await connect(url.toString());
|
|
153
223
|
try {
|
|
154
|
-
|
|
155
|
-
|
|
224
|
+
await admin.query(`CREATE DATABASE ${escapeIdentifier(name)}`);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (!madeAlready(err)) throw err;
|
|
156
227
|
} finally {
|
|
157
|
-
await
|
|
228
|
+
await admin.end();
|
|
158
229
|
}
|
|
159
230
|
}
|
|
160
231
|
|
|
161
232
|
/**
|
|
162
233
|
* Sets `idle_in_transaction_session_timeout` on the database, for each session that starts after,
|
|
163
234
|
* as DBOS would per connection (`DBOS_DATABASE_OPTIONS`), unless the server, the role or the
|
|
164
|
-
* database sets it already.
|
|
165
|
-
*
|
|
235
|
+
* database sets it already. `db` holds the preparation's lock, so one process at a time. A role
|
|
236
|
+
* that may not alter the database leaves it, quietly: its administrator decides.
|
|
166
237
|
*/
|
|
167
238
|
async function guardIdleTransactions(db: Db): Promise<void> {
|
|
168
239
|
try {
|
|
169
240
|
await db.query(`DO $$ BEGIN
|
|
170
|
-
PERFORM pg_advisory_xact_lock(hashtext('sanoma.idle_in_transaction_session_timeout'));
|
|
171
241
|
IF current_setting('idle_in_transaction_session_timeout') = '0' AND NOT EXISTS (
|
|
172
242
|
SELECT FROM pg_db_role_setting s JOIN pg_database d ON d.oid = s.setdatabase, unnest(s.setconfig) c
|
|
173
243
|
WHERE d.datname = current_database() AND s.setrole = 0 AND c LIKE 'idle_in_transaction_session_timeout=%'
|
|
@@ -177,9 +247,7 @@ async function guardIdleTransactions(db: Db): Promise<void> {
|
|
|
177
247
|
END IF;
|
|
178
248
|
END $$`);
|
|
179
249
|
} catch (err) {
|
|
180
|
-
if (pgCode(err) !== INSUFFICIENT_PRIVILEGE)
|
|
181
|
-
throw new Error(`could not prepare the database: ${errorMessage(err)}`, { cause: err });
|
|
182
|
-
}
|
|
250
|
+
if (pgCode(err) !== INSUFFICIENT_PRIVILEGE) throw unprepared(err);
|
|
183
251
|
}
|
|
184
252
|
}
|
|
185
253
|
|
package/src/define.ts
CHANGED
|
@@ -2,20 +2,24 @@ import { fileURLToPath } from "node:url";
|
|
|
2
2
|
import { getCallSites } from "node:util";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { isOp, type Op } from "./op.ts";
|
|
5
|
-
import { approverLabel } from "./shared.ts";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
|
-
* Who is acting: the person or
|
|
9
|
-
*
|
|
7
|
+
* Who is acting: the person or agent that starts a run, calls an operation or decides an
|
|
8
|
+
* approval. With people declared (`sanoma.person`, `sanoma.agent`, `sanoma.group` in data files),
|
|
9
|
+
* a principal is a declared one, its `groups` every group that lists it; an agent is in the group
|
|
10
|
+
* `agents`, and `onBehalfOf` names the person it acts for.
|
|
10
11
|
*/
|
|
11
12
|
export interface Principal {
|
|
12
13
|
id: string;
|
|
13
14
|
groups?: string[];
|
|
15
|
+
/** For an agent: the id of the person whose instruction it carries. */
|
|
16
|
+
onBehalfOf?: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export const Principal: z.ZodType<Principal> = z.object({
|
|
17
20
|
id: z.string().regex(/\S/, "id must not be blank"),
|
|
18
21
|
groups: z.array(z.string()).optional(),
|
|
22
|
+
onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
|
|
19
23
|
});
|
|
20
24
|
|
|
21
25
|
export type Builtin = "approval" | "sleep" | "all";
|
|
@@ -29,12 +33,6 @@ export const Approver: z.ZodType<Approver> = z.union(
|
|
|
29
33
|
{ error: 'needs an approver: a name, or { group: "name" }' },
|
|
30
34
|
);
|
|
31
35
|
|
|
32
|
-
/** Why `by` may not decide the approval, naming who may. */
|
|
33
|
-
export const notApprover = ({ approver }: Pick<ApprovalState, "approver">, by: Principal): string =>
|
|
34
|
-
typeof approver === "string"
|
|
35
|
-
? `${by.id} is not the approver; ${approverLabel(approver)} is`
|
|
36
|
-
: `${by.id} is not in ${approverLabel(approver)}`;
|
|
37
|
-
|
|
38
36
|
/** True when the two name the same approver: the same id, or the same group. */
|
|
39
37
|
export const sameApprover = (a: Approver, b: Approver): boolean =>
|
|
40
38
|
typeof a === "string" || typeof b === "string" ? a === b : a.group === b.group;
|
|
@@ -92,6 +90,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
|
|
|
92
90
|
opSeq?: number;
|
|
93
91
|
/** For a policy request: the held call's input. */
|
|
94
92
|
input?: unknown;
|
|
93
|
+
/** For a policy request: the policies that held the call, by id, when the policy names them. */
|
|
94
|
+
reasons?: string[];
|
|
95
95
|
status: "pending" | "approved" | "rejected";
|
|
96
96
|
requestedAt: number;
|
|
97
97
|
decidedBy?: string;
|
|
@@ -99,6 +99,8 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
|
|
|
99
99
|
note?: string;
|
|
100
100
|
/** The `id` of the decision message the run decided with, when it had one. */
|
|
101
101
|
decidedWith?: string;
|
|
102
|
+
/** Set when the run's own actor decided it: nobody else could (`mayDecide`). */
|
|
103
|
+
alone?: true;
|
|
102
104
|
/**
|
|
103
105
|
* Messages that were ignored: decisions sent by someone who may not decide the approval,
|
|
104
106
|
* and messages that were not a decision. `by` is the sender, when the message named one, and
|
package/src/describe.ts
CHANGED
|
@@ -1,29 +1,56 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { type Field, recordSchemaOf } from "./collection.ts";
|
|
4
|
+
import { connectorsOf, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
|
|
4
5
|
import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
|
|
6
|
+
import { isDirect } from "./direct.ts";
|
|
5
7
|
import { lintWorkflow } from "./lint.ts";
|
|
6
8
|
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
7
9
|
import { type Outline, outlineWithSource } from "./outline.ts";
|
|
8
10
|
import { allowAll, policyOpOf } from "./policy.ts";
|
|
9
11
|
import { type ResourceFields, resourceTypesOf } from "./resource.ts";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
+
import {
|
|
13
|
+
type DataFiles,
|
|
14
|
+
type DeclaredCollection,
|
|
15
|
+
type DeclaredResource,
|
|
16
|
+
readDataFiles,
|
|
17
|
+
type ResourceProblem,
|
|
18
|
+
} from "./resources.ts";
|
|
19
|
+
import { apiNameProblem } from "./sdk.ts";
|
|
20
|
+
import { apiNameOf, BUILT_IN_WORKFLOWS, DATA_VENDOR, problemLine } from "./shared.ts";
|
|
21
|
+
import { readSites, type SiteEntry } from "./sites.ts";
|
|
12
22
|
|
|
13
23
|
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
|
|
14
24
|
export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
|
|
15
25
|
// The reader parses data files with oxc-parser too.
|
|
16
26
|
export type { LintProblem } from "./ast.ts";
|
|
17
27
|
export {
|
|
28
|
+
admit,
|
|
18
29
|
readDataFiles,
|
|
19
|
-
|
|
30
|
+
readPeople,
|
|
20
31
|
type DataFiles,
|
|
32
|
+
type DeclaredCollection,
|
|
33
|
+
type DeclaredPeople,
|
|
21
34
|
type DeclaredResource,
|
|
22
35
|
type ResourceProblem,
|
|
23
36
|
} from "./resources.ts";
|
|
24
37
|
// The writer: setting a resource's fields in its data file, and rendering a new one.
|
|
25
38
|
export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
|
|
26
39
|
export { Reference, type DataValue } from "./datafile.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The resources the config's data files declare, read without importing or running them; throws
|
|
43
|
+
* one error listing every problem, each at `file:line:column`, for a test or CI to fail on.
|
|
44
|
+
* `describeConfig` lists the same, with the problems beside them instead.
|
|
45
|
+
*/
|
|
46
|
+
export function readResources(config: SanomaConfig): DeclaredResource[] {
|
|
47
|
+
const { resources, problems } = readDataFiles(rootOf(config), connectorsOf(config));
|
|
48
|
+
if (problems.length) {
|
|
49
|
+
const lines = problems.map((p) => ` ${problemLine(p)}`);
|
|
50
|
+
throw new Error(`The resources' data files have problems:\n${lines.join("\n")}`);
|
|
51
|
+
}
|
|
52
|
+
return resources;
|
|
53
|
+
}
|
|
27
54
|
export {
|
|
28
55
|
LOCKFILE,
|
|
29
56
|
readLockfile,
|
|
@@ -34,6 +61,12 @@ export {
|
|
|
34
61
|
type Bindings,
|
|
35
62
|
type Rebind,
|
|
36
63
|
} from "./lockfile.ts";
|
|
64
|
+
// What the sites call, read from their source: a workflow's Used by.
|
|
65
|
+
export { readSites, type SiteCall, type SiteEntry } from "./sites.ts";
|
|
66
|
+
// The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
|
|
67
|
+
export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
|
|
68
|
+
// The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
|
|
69
|
+
export { SCHEMA_FILE, schemaOf, schemaText, type CedarSchemaJson } from "./schema.ts";
|
|
37
70
|
// The apply workflow's plan and report, which the app renders.
|
|
38
71
|
export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
39
72
|
|
|
@@ -43,7 +76,7 @@ export interface WorkflowEntry {
|
|
|
43
76
|
title?: string;
|
|
44
77
|
trigger: "manual";
|
|
45
78
|
/**
|
|
46
|
-
* Set for a workflow the runtime adds itself, `drift` or `
|
|
79
|
+
* Set for a workflow the runtime adds itself, `drift`, `apply` or `call`, rather than the config: a label. It
|
|
47
80
|
* starts like any other.
|
|
48
81
|
*/
|
|
49
82
|
builtin?: true;
|
|
@@ -84,6 +117,8 @@ export interface OpEntry {
|
|
|
84
117
|
description?: string;
|
|
85
118
|
/** How a scenario's steps name it: Cucumber expressions over its input's fields. */
|
|
86
119
|
phrases?: { given?: string; expect?: string };
|
|
120
|
+
/** Whether it may be called directly, outside a run (`isDirect`): the one rule, read here, never derived again. */
|
|
121
|
+
direct: boolean;
|
|
87
122
|
/** What a call sends, as JSON Schema (`io: "input"`: fields with defaults are optional). */
|
|
88
123
|
input: Record<string, unknown>;
|
|
89
124
|
/** What a call returns once its schema has parsed the vendor's reply (`io: "output"`). */
|
|
@@ -124,6 +159,22 @@ export interface ResourceTypeEntry {
|
|
|
124
159
|
ops: string[];
|
|
125
160
|
}
|
|
126
161
|
|
|
162
|
+
/** A collection the config declares (`collection()`). */
|
|
163
|
+
export interface CollectionEntry {
|
|
164
|
+
name: string;
|
|
165
|
+
title?: string;
|
|
166
|
+
/** The field each record is found by. */
|
|
167
|
+
key: string;
|
|
168
|
+
/** Its fields as declared. */
|
|
169
|
+
fields: Record<string, Field>;
|
|
170
|
+
/** What `save` accepts, as JSON Schema, with `$id` `sanoma:collection/<name>`. */
|
|
171
|
+
record: Record<string, unknown>;
|
|
172
|
+
/** Its operations' ids: `data.<name>.get`, `.list`, `.save` and `.delete`. */
|
|
173
|
+
ops: string[];
|
|
174
|
+
/** Where `resources/data.ts` declares it, read as the data files are; absent for one declared elsewhere. */
|
|
175
|
+
declared?: DeclaredCollection;
|
|
176
|
+
}
|
|
177
|
+
|
|
127
178
|
/**
|
|
128
179
|
* A plain-JSON description of what a config can do: its workflows, the operations they call,
|
|
129
180
|
* and whether a policy gates them. The app server builds it from the config and sends it to the
|
|
@@ -139,6 +190,8 @@ export interface ConfigDescription {
|
|
|
139
190
|
vendors: Record<string, VendorEntry>;
|
|
140
191
|
/** The resource types the connectors declare, by `id`. */
|
|
141
192
|
resourceTypes: ResourceTypeEntry[];
|
|
193
|
+
/** The config's collections, by name. */
|
|
194
|
+
collections: CollectionEntry[];
|
|
142
195
|
/**
|
|
143
196
|
* The resources the data files under `resources/` declare, by file and then in file order,
|
|
144
197
|
* less any with a problem and any that names one.
|
|
@@ -148,8 +201,16 @@ export interface ConfigDescription {
|
|
|
148
201
|
orphans: DataFiles["orphans"];
|
|
149
202
|
/** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
|
|
150
203
|
problems: ResourceProblem[];
|
|
151
|
-
/**
|
|
152
|
-
|
|
204
|
+
/**
|
|
205
|
+
* `defined` when the root has `policies/**\/*.cedar` (else the policy is `allowAll`), with their
|
|
206
|
+
* `version`, or while they have `problems` (`policyProblems`), none: then every call and start is refused.
|
|
207
|
+
*/
|
|
208
|
+
policy: { defined: boolean; version?: string; problems?: string[] };
|
|
209
|
+
/**
|
|
210
|
+
* The sites under `sites/`, by name, with what each calls (`readSites`), and its calls to what
|
|
211
|
+
* the config does not have as its problems; none when the root has no `sites/`.
|
|
212
|
+
*/
|
|
213
|
+
sites: SiteEntry[];
|
|
153
214
|
}
|
|
154
215
|
|
|
155
216
|
/**
|
|
@@ -157,7 +218,8 @@ export interface ConfigDescription {
|
|
|
157
218
|
* credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
|
|
158
219
|
* file, which the worker refuses when it reads the outline: that is data, on each workflow's
|
|
159
220
|
* entry, as the data files' problems are, `problems`, beside the resources read without any.
|
|
160
|
-
* Each workflow's file is read and parsed once, however many workflows it defines.
|
|
221
|
+
* Each workflow's file is read and parsed once, however many workflows it defines. The sites'
|
|
222
|
+
* code is read too (`readSites`): what each calls, and its calls to what the config lacks as its problems.
|
|
161
223
|
*/
|
|
162
224
|
export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
163
225
|
const resolved = resolveConfig(config);
|
|
@@ -168,7 +230,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
168
230
|
const { id, info } = connector[VENDOR];
|
|
169
231
|
vendors[id] ??= vendorEntry(id, info, resolved.fakes.has(id));
|
|
170
232
|
}
|
|
171
|
-
const resources = resourceTypesOf(config
|
|
233
|
+
const resources = resourceTypesOf(connectorsOf(config));
|
|
172
234
|
// Each resource type's state is described once, on its entry, and referenced from its operations.
|
|
173
235
|
const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
|
|
174
236
|
const resourceTypes: ResourceTypeEntry[] = [...resources]
|
|
@@ -186,11 +248,26 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
186
248
|
}))
|
|
187
249
|
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
188
250
|
|
|
251
|
+
const dataFiles = readDataFiles(resolved.root, connectorsOf(config), resources);
|
|
252
|
+
const declared = new Map(dataFiles.collections.map((d) => [d.name, d]));
|
|
253
|
+
const collections: CollectionEntry[] = [...resolved.collections.values()]
|
|
254
|
+
.map((c) => ({
|
|
255
|
+
name: c.name,
|
|
256
|
+
...(c.title !== undefined && { title: c.title }),
|
|
257
|
+
key: c.key,
|
|
258
|
+
fields: c.fields,
|
|
259
|
+
record: { $id: `sanoma:collection/${c.name}`, ...toJsonSchema(recordSchemaOf(c), `${c.name} record`, "input") },
|
|
260
|
+
ops: [c.get, c.list, c.save, c.delete].map((op) => op.id),
|
|
261
|
+
...(declared.has(c.name) && { declared: declared.get(c.name) }),
|
|
262
|
+
}))
|
|
263
|
+
.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
264
|
+
|
|
189
265
|
const ops: OpEntry[] = [...resolved.ops.values()].map((op) => ({
|
|
190
266
|
...policyOpOf(op),
|
|
191
267
|
idempotent: op.idempotent,
|
|
192
268
|
description: op.description,
|
|
193
269
|
...(op.phrases && { phrases: op.phrases }),
|
|
270
|
+
direct: isDirect(op, resolved.types),
|
|
194
271
|
input: toJsonSchema(op.input, `${op.id} input`, "input", refs),
|
|
195
272
|
output: toJsonSchema(op.output, `${op.id} output`, "output", refs),
|
|
196
273
|
}));
|
|
@@ -225,7 +302,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
225
302
|
};
|
|
226
303
|
});
|
|
227
304
|
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
228
|
-
|
|
305
|
+
addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
|
|
229
306
|
|
|
230
307
|
return {
|
|
231
308
|
appName: resolved.appName,
|
|
@@ -234,16 +311,42 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
234
311
|
ops,
|
|
235
312
|
vendors,
|
|
236
313
|
resourceTypes,
|
|
314
|
+
collections,
|
|
237
315
|
resources: dataFiles.resources,
|
|
238
316
|
orphans: dataFiles.orphans,
|
|
239
317
|
problems: dataFiles.problems,
|
|
240
318
|
policy: {
|
|
241
319
|
defined: resolved.policy !== allowAll,
|
|
242
320
|
...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
|
|
321
|
+
...(resolved.policyProblems.length ? { problems: resolved.policyProblems } : {}),
|
|
243
322
|
},
|
|
323
|
+
sites: readSites(resolved.root, { workflows, collections, ops }),
|
|
244
324
|
};
|
|
245
325
|
}
|
|
246
326
|
|
|
327
|
+
/**
|
|
328
|
+
* A site's `api` names each workflow `apiNameOf(name)` beside the vendors' operations and the
|
|
329
|
+
* collections (`api.data`), so a workflow whose name comes out as another's, a vendor's id or
|
|
330
|
+
* `data` is a problem, at 1:1 of its file. A built-in is never the one renamed.
|
|
331
|
+
*/
|
|
332
|
+
function addApiNameProblems(workflows: WorkflowEntry[], vendors: ReadonlySet<string>) {
|
|
333
|
+
for (const wf of workflows) {
|
|
334
|
+
if (wf.builtin) continue;
|
|
335
|
+
const api = apiNameOf(wf.name);
|
|
336
|
+
const others = workflows.filter((other) => other !== wf && apiNameOf(other.name) === api);
|
|
337
|
+
const taken = others.length
|
|
338
|
+
? `as workflow ${others.map((other) => `"${other.name}"`).join(" and ")} is`
|
|
339
|
+
: api === DATA_VENDOR
|
|
340
|
+
? "which is the collections'"
|
|
341
|
+
: vendors.has(api)
|
|
342
|
+
? `which is the vendor ${api}'s operations`
|
|
343
|
+
: api === "then"
|
|
344
|
+
? "which a promise would take for its own"
|
|
345
|
+
: undefined;
|
|
346
|
+
if (taken) wf.problems.push({ line: 1, column: 1, message: apiNameProblem(api, taken) });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
247
350
|
/** `fn`, called once per argument for as long as the returned function lives. */
|
|
248
351
|
function memoised<A, R>(fn: (arg: A) => R): (arg: A) => R {
|
|
249
352
|
const seen = new Map<A, R>();
|
package/src/direct.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Op } from "./op.ts";
|
|
2
|
+
import { isResourceOp } from "./resource.ts";
|
|
3
|
+
import { DATA_VENDOR } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether an operation may be called directly, outside a run (`SanomaClient.call`), as the
|
|
7
|
+
* person: a collection's (`data.<name>.<op>`), or a `read` that is neither the runtime's own
|
|
8
|
+
* (the vendor `sanoma`) nor a resource type's (`isResourceOp`), which only drift and apply call.
|
|
9
|
+
* Anything else (an effect at a vendor) is a workflow's. The one rule: the built-in `call`
|
|
10
|
+
* workflow, the SDK and the app read it, from here or from `OpEntry.direct`, and never derive it.
|
|
11
|
+
*/
|
|
12
|
+
export const isDirect = (
|
|
13
|
+
op: Pick<Op, "vendor" | "resource" | "effect">,
|
|
14
|
+
types: ReadonlyMap<string, unknown>,
|
|
15
|
+
): boolean => op.vendor === DATA_VENDOR || (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
import type { Principal } from "./define.ts";
|
|
4
|
-
import { errorMessage } from "./shared.ts";
|
|
4
|
+
import { CALL_WORKFLOW, errorMessage } from "./shared.ts";
|
|
5
5
|
|
|
6
6
|
const CODES = [
|
|
7
7
|
"policy_denied",
|
|
@@ -15,6 +15,8 @@ const CODES = [
|
|
|
15
15
|
"run_ended",
|
|
16
16
|
"run_running",
|
|
17
17
|
"call_not_in_outline",
|
|
18
|
+
"approval_pending",
|
|
19
|
+
"not_admitted",
|
|
18
20
|
] as const;
|
|
19
21
|
|
|
20
22
|
/**
|
|
@@ -79,6 +81,39 @@ export class PolicyDeniedError extends SanomaError {
|
|
|
79
81
|
}
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Thrown by a direct call (`SanomaClient.call`) the policy holds for an approval: the call is now
|
|
86
|
+
* run `runId` of the built-in `call` workflow, which makes it once approved.
|
|
87
|
+
*/
|
|
88
|
+
export class ApprovalPendingError extends SanomaError {
|
|
89
|
+
constructor(op: string, runId: string) {
|
|
90
|
+
super("approval_pending", `${op} needs an approval: it waits in run ${runId}`, {
|
|
91
|
+
runId,
|
|
92
|
+
workflow: CALL_WORKFLOW,
|
|
93
|
+
op,
|
|
94
|
+
});
|
|
95
|
+
this.name = "ApprovalPendingError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Thrown when the data files declare people (`sanoma.person`, in `resources/people.ts` by
|
|
101
|
+
* convention) and not this principal's id: nothing is started, called or decided.
|
|
102
|
+
*/
|
|
103
|
+
export class NotAdmittedError extends SanomaError {
|
|
104
|
+
/** `problems`: what is wrong in the people's files, while nobody is admitted. */
|
|
105
|
+
constructor(id: string, problems: readonly string[] = []) {
|
|
106
|
+
super(
|
|
107
|
+
"not_admitted",
|
|
108
|
+
problems.length
|
|
109
|
+
? `${id} cannot be admitted while the people's files have problems: ${problems.join("; ")}`
|
|
110
|
+
: `${id} is not declared in resources/people.ts`,
|
|
111
|
+
{ principal: id, ...(problems.length && { problems }) },
|
|
112
|
+
);
|
|
113
|
+
this.name = "NotAdmittedError";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
/** Thrown into the run when an approver rejects. The run fails with it. */
|
|
83
118
|
export class RejectedError extends SanomaError {
|
|
84
119
|
/**
|
package/src/expectations.ts
CHANGED
|
@@ -83,9 +83,13 @@ const words = (d: RecordedDecision) =>
|
|
|
83
83
|
/** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
|
|
84
84
|
const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
|
|
85
85
|
|
|
86
|
-
/** True when `who` names the approver: a person by id, or a group by its name. */
|
|
87
|
-
const isApprover = (approver: Approver, who:
|
|
88
|
-
typeof
|
|
86
|
+
/** True when `who` names the approver: a person by id, or a group by its name; `{ group }` the group alone. */
|
|
87
|
+
const isApprover = (approver: Approver, who: Approver) =>
|
|
88
|
+
typeof who !== "string"
|
|
89
|
+
? typeof approver !== "string" && approver.group === who.group
|
|
90
|
+
: typeof approver === "string"
|
|
91
|
+
? approver === who
|
|
92
|
+
: approver.group === who;
|
|
89
93
|
|
|
90
94
|
/** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
|
|
91
95
|
const found = (r: DriftResult) =>
|
|
@@ -144,9 +148,9 @@ function applyDetail(
|
|
|
144
148
|
* Why an expectation of the generic steps does not hold against a run's ledger records, or
|
|
145
149
|
* undefined when it does. A call is an `op.called` record without an `error`; one with an error
|
|
146
150
|
* (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
|
|
147
|
-
* operation `was denied` when the policy's decision on some call to it is `deny
|
|
148
|
-
*
|
|
149
|
-
* call, or one that `covers` it. A drift or apply expectation reads the report a finished run
|
|
151
|
+
* operation `was denied` when the policy's decision on some call to it is `deny` (for the `reason`
|
|
152
|
+
* expected, when there is one), and `was held for` an approver (a group alone, as `{ group }`) when
|
|
153
|
+
* an approval for that approver holds it: one the policy asked for on its call, or one that `covers` it. A drift or apply expectation reads the report a finished run
|
|
150
154
|
* of it returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
151
155
|
* (its calls made, and `waitsOf`); a record written before steps had names has none.
|
|
152
156
|
*/
|
|
@@ -158,13 +162,18 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
|
|
|
158
162
|
if ("decision" in e) {
|
|
159
163
|
const tries = callsTo(e.op);
|
|
160
164
|
if (e.decision === "denied") {
|
|
161
|
-
|
|
165
|
+
const denials = tries.flatMap(({ decision: d }) => (d.kind === "deny" ? [d.reason] : []));
|
|
166
|
+
if (denials.some((reason) => e.reason === undefined || reason === e.reason)) return undefined;
|
|
167
|
+
if (denials.length) {
|
|
168
|
+
const said = [...new Set(denials)].map((reason) => JSON.stringify(reason)).join(" and ");
|
|
169
|
+
return `${e.op} was denied with ${said}, not ${JSON.stringify(e.reason)}`;
|
|
170
|
+
}
|
|
162
171
|
return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
|
|
163
172
|
}
|
|
164
173
|
const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
|
|
165
174
|
if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
|
|
166
175
|
return holds.length
|
|
167
|
-
? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
|
|
176
|
+
? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${approverLabel(e.approver)}`
|
|
168
177
|
: tries.length
|
|
169
178
|
? `${e.op} was ${decisions(tries)}, not held`
|
|
170
179
|
: `no call to ${e.op}`;
|