@sanoma-ai/workflows 0.1.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- 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 +35 -2
- package/dist/call.js +123 -43
- 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 +123 -9
- package/dist/client.js +355 -34
- 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 +109 -28
- 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/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- 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 +38 -3
- package/dist/datafile.js +166 -13
- 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 +54 -8
- package/dist/describe.js +84 -11
- 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/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- 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 +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- 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/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -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 +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- 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/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- 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/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.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 +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -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 +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
package/src/describe.ts
CHANGED
|
@@ -1,26 +1,74 @@
|
|
|
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";
|
|
5
|
-
import {
|
|
6
|
+
import { isDirect } from "./direct.ts";
|
|
6
7
|
import { lintWorkflow } from "./lint.ts";
|
|
7
8
|
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
8
9
|
import { type Outline, outlineWithSource } from "./outline.ts";
|
|
9
10
|
import { allowAll, policyOpOf } from "./policy.ts";
|
|
10
11
|
import { type ResourceFields, resourceTypesOf } from "./resource.ts";
|
|
11
|
-
import {
|
|
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";
|
|
37
|
+
// The writer: setting a resource's fields in its data file, and rendering a new one.
|
|
38
|
+
export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
|
|
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
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
LOCKFILE,
|
|
56
|
+
readLockfile,
|
|
57
|
+
serializeLockfile,
|
|
58
|
+
writeLockfile,
|
|
59
|
+
diffLockfiles,
|
|
60
|
+
type Binding,
|
|
61
|
+
type Bindings,
|
|
62
|
+
type Rebind,
|
|
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";
|
|
70
|
+
// The apply workflow's plan and report, which the app renders.
|
|
71
|
+
export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
24
72
|
|
|
25
73
|
/** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
|
|
26
74
|
export interface WorkflowEntry {
|
|
@@ -28,7 +76,7 @@ export interface WorkflowEntry {
|
|
|
28
76
|
title?: string;
|
|
29
77
|
trigger: "manual";
|
|
30
78
|
/**
|
|
31
|
-
* Set for a workflow the runtime adds itself, `drift`, rather than the config: a label. It
|
|
79
|
+
* Set for a workflow the runtime adds itself, `drift`, `apply` or `call`, rather than the config: a label. It
|
|
32
80
|
* starts like any other.
|
|
33
81
|
*/
|
|
34
82
|
builtin?: true;
|
|
@@ -69,6 +117,8 @@ export interface OpEntry {
|
|
|
69
117
|
description?: string;
|
|
70
118
|
/** How a scenario's steps name it: Cucumber expressions over its input's fields. */
|
|
71
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;
|
|
72
122
|
/** What a call sends, as JSON Schema (`io: "input"`: fields with defaults are optional). */
|
|
73
123
|
input: Record<string, unknown>;
|
|
74
124
|
/** What a call returns once its schema has parsed the vendor's reply (`io: "output"`). */
|
|
@@ -96,8 +146,8 @@ export interface ResourceTypeEntry {
|
|
|
96
146
|
vendor: string;
|
|
97
147
|
type: string;
|
|
98
148
|
title: string;
|
|
99
|
-
/** How a declared resource's
|
|
100
|
-
identity
|
|
149
|
+
/** How a declared resource's identity is made, such as `name` or `repository_id:pattern`; absent when it is found only by its binding. */
|
|
150
|
+
identity?: string;
|
|
101
151
|
/** Its flagged fields, by dotted path. */
|
|
102
152
|
fields: ResourceFields;
|
|
103
153
|
/**
|
|
@@ -105,10 +155,26 @@ export interface ResourceTypeEntry {
|
|
|
105
155
|
* `sanoma:resource-type/<id>`: the operations' contracts `$ref` it instead of repeating it.
|
|
106
156
|
*/
|
|
107
157
|
schema: Record<string, unknown>;
|
|
108
|
-
/** Its operations' ids: `<id>.import` and `<id>.read
|
|
158
|
+
/** Its operations' ids, sorted: `<id>.import` and `<id>.read`, and for a writable type its change operations. */
|
|
109
159
|
ops: string[];
|
|
110
160
|
}
|
|
111
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
|
+
|
|
112
178
|
/**
|
|
113
179
|
* A plain-JSON description of what a config can do: its workflows, the operations they call,
|
|
114
180
|
* and whether a policy gates them. The app server builds it from the config and sends it to the
|
|
@@ -124,15 +190,27 @@ export interface ConfigDescription {
|
|
|
124
190
|
vendors: Record<string, VendorEntry>;
|
|
125
191
|
/** The resource types the connectors declare, by `id`. */
|
|
126
192
|
resourceTypes: ResourceTypeEntry[];
|
|
193
|
+
/** The config's collections, by name. */
|
|
194
|
+
collections: CollectionEntry[];
|
|
127
195
|
/**
|
|
128
196
|
* The resources the data files under `resources/` declare, by file and then in file order,
|
|
129
197
|
* less any with a problem and any that names one.
|
|
130
198
|
*/
|
|
131
199
|
resources: DeclaredResource[];
|
|
200
|
+
/** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
|
|
201
|
+
orphans: DataFiles["orphans"];
|
|
132
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. */
|
|
133
203
|
problems: ResourceProblem[];
|
|
134
|
-
/**
|
|
135
|
-
|
|
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[];
|
|
136
214
|
}
|
|
137
215
|
|
|
138
216
|
/**
|
|
@@ -140,7 +218,8 @@ export interface ConfigDescription {
|
|
|
140
218
|
* credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
|
|
141
219
|
* file, which the worker refuses when it reads the outline: that is data, on each workflow's
|
|
142
220
|
* entry, as the data files' problems are, `problems`, beside the resources read without any.
|
|
143
|
-
* 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.
|
|
144
223
|
*/
|
|
145
224
|
export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
146
225
|
const resolved = resolveConfig(config);
|
|
@@ -151,11 +230,11 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
151
230
|
const { id, info } = connector[VENDOR];
|
|
152
231
|
vendors[id] ??= vendorEntry(id, info, resolved.fakes.has(id));
|
|
153
232
|
}
|
|
154
|
-
const resources = resourceTypesOf(config
|
|
233
|
+
const resources = resourceTypesOf(connectorsOf(config));
|
|
155
234
|
// Each resource type's state is described once, on its entry, and referenced from its operations.
|
|
156
235
|
const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
|
|
157
236
|
const resourceTypes: ResourceTypeEntry[] = [...resources]
|
|
158
|
-
.map(([id, { vendor, type, title, identity, fields, schema }]) => ({
|
|
237
|
+
.map(([id, { vendor, type, title, identity, fields, schema, ops }]) => ({
|
|
159
238
|
id,
|
|
160
239
|
vendor,
|
|
161
240
|
type,
|
|
@@ -163,15 +242,32 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
163
242
|
identity,
|
|
164
243
|
fields,
|
|
165
244
|
schema: { $id: resourceTypeUri(id), ...toJsonSchema(schema, `${id} state`, "output") },
|
|
166
|
-
ops:
|
|
245
|
+
ops: Object.keys(ops)
|
|
246
|
+
.map((name) => `${id}.${name}`)
|
|
247
|
+
.toSorted(),
|
|
167
248
|
}))
|
|
168
249
|
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
169
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
|
+
|
|
170
265
|
const ops: OpEntry[] = [...resolved.ops.values()].map((op) => ({
|
|
171
266
|
...policyOpOf(op),
|
|
172
267
|
idempotent: op.idempotent,
|
|
173
268
|
description: op.description,
|
|
174
269
|
...(op.phrases && { phrases: op.phrases }),
|
|
270
|
+
direct: isDirect(op, resolved.types),
|
|
175
271
|
input: toJsonSchema(op.input, `${op.id} input`, "input", refs),
|
|
176
272
|
output: toJsonSchema(op.output, `${op.id} output`, "output", refs),
|
|
177
273
|
}));
|
|
@@ -184,18 +280,18 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
184
280
|
const { outline, source, found } = outlineWithSource(wf, file);
|
|
185
281
|
// What the lint does not report: the file unread, or no workflow of this name in it.
|
|
186
282
|
const own = "error" in outline && !found && (typeof file === "string" || !file.problems.length);
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
283
|
+
const builtin = BUILT_IN_WORKFLOWS.has(wf.name);
|
|
284
|
+
const problems = builtin
|
|
285
|
+
? []
|
|
286
|
+
: [
|
|
287
|
+
...(typeof file === "string" ? [] : lint(file)),
|
|
288
|
+
...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
|
|
289
|
+
];
|
|
194
290
|
return {
|
|
195
291
|
name: wf.name,
|
|
196
292
|
title: wf.title,
|
|
197
293
|
trigger: wf.trigger,
|
|
198
|
-
...(
|
|
294
|
+
...(builtin && { builtin: true as const }),
|
|
199
295
|
input: toJsonSchema(wf.input, `${wf.name} input`, "input"),
|
|
200
296
|
ops: opIdsOf(wf),
|
|
201
297
|
builtins: (wf.uses as Use[]).filter((u): u is Builtin => typeof u === "string"),
|
|
@@ -206,7 +302,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
206
302
|
};
|
|
207
303
|
});
|
|
208
304
|
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
209
|
-
|
|
305
|
+
addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
|
|
210
306
|
|
|
211
307
|
return {
|
|
212
308
|
appName: resolved.appName,
|
|
@@ -215,15 +311,42 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
215
311
|
ops,
|
|
216
312
|
vendors,
|
|
217
313
|
resourceTypes,
|
|
314
|
+
collections,
|
|
218
315
|
resources: dataFiles.resources,
|
|
316
|
+
orphans: dataFiles.orphans,
|
|
219
317
|
problems: dataFiles.problems,
|
|
220
318
|
policy: {
|
|
221
319
|
defined: resolved.policy !== allowAll,
|
|
222
320
|
...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
|
|
321
|
+
...(resolved.policyProblems.length ? { problems: resolved.policyProblems } : {}),
|
|
223
322
|
},
|
|
323
|
+
sites: readSites(resolved.root, { workflows, collections, ops }),
|
|
224
324
|
};
|
|
225
325
|
}
|
|
226
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
|
+
|
|
227
350
|
/** `fn`, called once per argument for as long as the returned function lives. */
|
|
228
351
|
function memoised<A, R>(fn: (arg: A) => R): (arg: A) => R {
|
|
229
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/drift.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { z } from "zod";
|
|
|
2
2
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
3
|
import { isRunControlError } from "./errors.ts";
|
|
4
4
|
import type { Op } from "./op.ts";
|
|
5
|
-
import { type DriftField, diffDeclared, type Resource } from "./resource.ts";
|
|
5
|
+
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
|
|
6
6
|
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
7
7
|
import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
|
|
8
|
+
import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
|
|
8
9
|
|
|
9
10
|
export type { DriftField } from "./resource.ts";
|
|
10
11
|
export { DRIFT_WORKFLOW } from "./shared.ts";
|
|
@@ -14,11 +15,12 @@ export { DRIFT_WORKFLOW } from "./shared.ts";
|
|
|
14
15
|
// vendor call goes through `ctx`: the policy sees each (effect `read`) and the ledger records it.
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* How a declared resource compares: `clean` when every declared field
|
|
18
|
-
* `drifted` when one does not, `gone` when the vendor
|
|
19
|
-
*
|
|
18
|
+
* How a declared resource compares: `clean` when every declared field of its bound object holds
|
|
19
|
+
* its declared value, `drifted` when one does not, `gone` when the vendor no longer has its bound
|
|
20
|
+
* object, `absent` when it is bound to none (whether or not its identity finds one: `found`), and
|
|
21
|
+
* `error` when it could not be read (the vendor's error, a policy denial, or no driver for its type).
|
|
20
22
|
*/
|
|
21
|
-
export type DriftStatus = "clean" | "drifted" | "gone" | "error";
|
|
23
|
+
export type DriftStatus = "clean" | "drifted" | "gone" | "absent" | "error";
|
|
22
24
|
|
|
23
25
|
/** One declared resource in a drift report. */
|
|
24
26
|
export interface DriftResult {
|
|
@@ -27,9 +29,13 @@ export interface DriftResult {
|
|
|
27
29
|
/** Its type within the vendor, such as `repository`. */
|
|
28
30
|
type: string;
|
|
29
31
|
vendor: string;
|
|
30
|
-
/**
|
|
32
|
+
/** What people call it: its identity, else its export's name. */
|
|
31
33
|
name: string;
|
|
32
34
|
status: DriftStatus;
|
|
35
|
+
/** The vendor's id of the object the lockfile binds it to; absent when unbound. */
|
|
36
|
+
binding?: string;
|
|
37
|
+
/** For `absent`: the binding id of the object its identity found, which apply may bind it to. */
|
|
38
|
+
found?: string;
|
|
33
39
|
/** The declared fields that differ; empty unless `drifted`. */
|
|
34
40
|
fields: DriftField[];
|
|
35
41
|
/** Why it could not be read, for `error`. */
|
|
@@ -59,7 +65,7 @@ export const DriftInput = z.object({}).strict();
|
|
|
59
65
|
* write-only values, and the problems.
|
|
60
66
|
*/
|
|
61
67
|
export interface DriftDeclared {
|
|
62
|
-
resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "desired" | "refs">[];
|
|
68
|
+
resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "identity" | "binding" | "desired" | "refs">[];
|
|
63
69
|
problems: ResourceProblem[];
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -67,9 +73,99 @@ type State = Record<string, unknown>;
|
|
|
67
73
|
|
|
68
74
|
// A resource type's import, as `ctx` gives it: by vendor and type, both only known per run.
|
|
69
75
|
type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
|
|
76
|
+
// The built-in `sanoma.source.resolve`, which a host's check calls for the commit its ref names now.
|
|
77
|
+
export type ResolveOp = typeof sourceOps.source.resolve;
|
|
78
|
+
|
|
79
|
+
/** A clone URL (a scheme's, or scp-like `user@host:path`): what a `by` field of a host's source holds, told from an id. */
|
|
80
|
+
const CLONE_URL = /^((https?|ssh|git|file):\/\/|[\w.-]+@[\w.-]+:)/;
|
|
70
81
|
|
|
71
82
|
/** What one import found: the object's state, that it is gone, or why it could not be read. */
|
|
72
|
-
type Found = { state: State } | { gone: true } | { error: string };
|
|
83
|
+
export type Found = { state: State } | { gone: true } | { error: string };
|
|
84
|
+
/** What a host's ref names now, or why it could not be resolved. */
|
|
85
|
+
export type Revision = { revision: string } | { error: string };
|
|
86
|
+
|
|
87
|
+
// What drift and apply share. Observing a resource is one `ctx` call, which a workflow makes in
|
|
88
|
+
// its own `run` (the outline draws only those): `observeBy` says what each import is called with,
|
|
89
|
+
// `observed` reads its answer, and `compare` turns what every import found into drift's results.
|
|
90
|
+
|
|
91
|
+
/** The `import` ops of the types among `ops` with a driver, by type, sorted: what observing them uses. */
|
|
92
|
+
export function importsOf(
|
|
93
|
+
types: ReadonlyMap<string, Resource>,
|
|
94
|
+
ops: ReadonlyMap<string, Op>,
|
|
95
|
+
drivers: ReadonlyMap<string, unknown>,
|
|
96
|
+
): ImportOp[] {
|
|
97
|
+
return [...types.keys()].toSorted().flatMap((type) => {
|
|
98
|
+
const op = ops.get(`${type}.import`);
|
|
99
|
+
return op && drivers.has(op.id) ? [op as ImportOp] : [];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a declared resource is observed by: the import id (`by`, default its binding, else its
|
|
105
|
+
* identity), or without one what it is found to be: `gone` for none, `error` when its type is
|
|
106
|
+
* not the connectors' or has no import among `imports`.
|
|
107
|
+
*/
|
|
108
|
+
export function observeBy(
|
|
109
|
+
{ vendor, type, binding, identity }: Pick<DeclaredResource, "vendor" | "type" | "binding" | "identity">,
|
|
110
|
+
types: ReadonlyMap<string, Resource>,
|
|
111
|
+
imports: readonly Pick<Op, "id">[],
|
|
112
|
+
by = binding ?? identity,
|
|
113
|
+
): { id: string } | Found {
|
|
114
|
+
const key = `${vendor}.${type}`;
|
|
115
|
+
if (!types.has(key)) return { error: `the config's connectors declare no resource type ${key}` };
|
|
116
|
+
if (!imports.some((op) => op.id === `${key}.import`)) return { error: `no driver for ${key}.import` };
|
|
117
|
+
return by === undefined ? { gone: true } : { id: by };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What an import's answer found. */
|
|
121
|
+
export const observed = (out: { gone: boolean; state?: State }): Found =>
|
|
122
|
+
out.gone || !out.state ? { gone: true } : { state: out.state };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
|
|
126
|
+
* binding id of what its identity found as `found`), `gone`, else the state and the declared
|
|
127
|
+
* fields compared by `diffDeclared`, a reference against the state of the resource it names
|
|
128
|
+
* (so GitHub's node id for a repository counts as its name). A host with a `revisions` entry
|
|
129
|
+
* also compares its revision against the commit its ref names now (or is that entry's `error`).
|
|
130
|
+
*/
|
|
131
|
+
export function compare(
|
|
132
|
+
types: ReadonlyMap<string, Resource>,
|
|
133
|
+
resources: DriftDeclared["resources"],
|
|
134
|
+
found: ReadonlyMap<string, Found>,
|
|
135
|
+
revisions: ReadonlyMap<string, Revision> = new Map(),
|
|
136
|
+
): DriftResult[] {
|
|
137
|
+
return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
|
|
138
|
+
const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
|
|
139
|
+
const now = found.get(id)!;
|
|
140
|
+
if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
|
|
141
|
+
const resource = types.get(`${vendor}.${type}`)!;
|
|
142
|
+
if (binding === undefined) {
|
|
143
|
+
return {
|
|
144
|
+
...row,
|
|
145
|
+
status: "absent",
|
|
146
|
+
fields: [],
|
|
147
|
+
...("state" in now && { found: resource.bindingId(now.state) }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if ("gone" in now) return { ...row, status: "gone", fields: [] };
|
|
151
|
+
// Each reference, by where it is: the values the vendor may hold there for its resource.
|
|
152
|
+
const accept: Record<string, unknown[]> = {};
|
|
153
|
+
for (const [at, target] of Object.entries(refs)) {
|
|
154
|
+
const by = resource.fields.references?.[fieldPathOf(at)]?.by ?? [];
|
|
155
|
+
const named = found.get(target);
|
|
156
|
+
if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
|
|
157
|
+
}
|
|
158
|
+
const fields = diffDeclared(resource, now.state, desired, accept);
|
|
159
|
+
const resolved = revisions.get(id);
|
|
160
|
+
if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
|
|
161
|
+
// A host whose state does not say which commit it runs is not compared.
|
|
162
|
+
const actual = resource.host?.outputs(now.state).revision;
|
|
163
|
+
if (resolved && actual !== undefined && actual !== resolved.revision) {
|
|
164
|
+
fields.push({ path: resource.host!.revision, desired: resolved.revision, actual });
|
|
165
|
+
}
|
|
166
|
+
return { ...row, status: fields.length ? "drifted" : "clean", fields };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
73
169
|
|
|
74
170
|
/**
|
|
75
171
|
* The drift workflow for a config: it `uses` the `import` of every resource type the connectors
|
|
@@ -77,11 +173,13 @@ type Found = { state: State } | { gone: true } | { error: string };
|
|
|
77
173
|
* files. `resolveConfig` adds it to a config whose connectors declare resource types, with
|
|
78
174
|
* `declared`, the step that reads the data files.
|
|
79
175
|
*
|
|
80
|
-
* A run reads the data files (`declared`, recorded as a step, so a replay checks
|
|
81
|
-
* then imports each resource by its
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
176
|
+
* A run reads the data files and the lockfile (`declared`, recorded as a step, so a replay checks
|
|
177
|
+
* the same list), then imports each resource by its binding, else by its identity, in the files'
|
|
178
|
+
* order: one vendor call each, none for a resource with neither. A bound resource whose import
|
|
179
|
+
* says gone is `gone`; an unbound one is `absent`, with the binding id of what its identity found
|
|
180
|
+
* as `found`. Else the state and the declared fields are compared by `diffDeclared`, a reference
|
|
181
|
+
* against the state of the resource it names, read in the same run (so GitHub's node id for a
|
|
182
|
+
* repository counts as its name). A failed call is that resource's
|
|
85
183
|
* `error`, and the run goes on to the next; a failure that ends the run (`isRunControlError`)
|
|
86
184
|
* ends it.
|
|
87
185
|
*/
|
|
@@ -90,16 +188,10 @@ export function driftWorkflow(
|
|
|
90
188
|
ops: ReadonlyMap<string, Op>,
|
|
91
189
|
drivers: ReadonlyMap<string, unknown>,
|
|
92
190
|
declared: () => Promise<DriftDeclared>,
|
|
93
|
-
): WorkflowDefinition<readonly ImportOp[], typeof DriftInput> {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
// Why a resource of each type cannot be read, if it cannot.
|
|
99
|
-
const unreadable = new Map<string, string>();
|
|
100
|
-
for (const type of types.keys()) {
|
|
101
|
-
if (!uses.some((op) => op.id === `${type}.import`)) unreadable.set(type, `no driver for ${type}.import`);
|
|
102
|
-
}
|
|
191
|
+
): WorkflowDefinition<readonly (ImportOp | ResolveOp)[], typeof DriftInput> {
|
|
192
|
+
const imports = importsOf(types, ops, drivers);
|
|
193
|
+
const resolve = ops.get(SOURCE_RESOLVE) as ResolveOp | undefined;
|
|
194
|
+
const uses = [...imports, ...(resolve ? [resolve] : [])];
|
|
103
195
|
return defineWorkflow({
|
|
104
196
|
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
105
197
|
name: "drift" satisfies typeof DRIFT_WORKFLOW,
|
|
@@ -112,38 +204,56 @@ export function driftWorkflow(
|
|
|
112
204
|
const { resources, problems } = await declared();
|
|
113
205
|
// Every import first, so a reference compares against what its resource holds now.
|
|
114
206
|
const found = new Map<string, Found>();
|
|
115
|
-
for (const
|
|
116
|
-
const
|
|
117
|
-
const why = types.has(key) ? unreadable.get(key) : `the config's connectors declare no resource type ${key}`;
|
|
207
|
+
for (const resource of resources) {
|
|
208
|
+
const by = observeBy(resource, types, imports);
|
|
118
209
|
// An if/else, not a `continue`: the outline draws the import as the way past the error.
|
|
119
|
-
if (
|
|
210
|
+
if (!("id" in by)) found.set(resource.id, by);
|
|
120
211
|
else {
|
|
121
212
|
try {
|
|
122
|
-
|
|
123
|
-
found.set(id, out.gone || !out.state ? { gone: true } : { state: out.state });
|
|
213
|
+
found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
|
|
124
214
|
} catch (err) {
|
|
125
215
|
if (isRunControlError(err)) throw err;
|
|
126
|
-
found.set(id, { error: errorMessage(err) });
|
|
216
|
+
found.set(resource.id, { error: errorMessage(err) });
|
|
127
217
|
}
|
|
128
218
|
}
|
|
129
219
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
220
|
+
// A host's revision against the commit its ref names now: a merge is drift on the host.
|
|
221
|
+
const revisions = new Map<string, Revision>();
|
|
222
|
+
for (const { id, vendor, type, desired, refs } of resources) {
|
|
223
|
+
const resource = types.get(`${vendor}.${type}`);
|
|
224
|
+
const host = resource?.host;
|
|
225
|
+
const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
|
|
226
|
+
const ref = host && valueAt(desired, host.inputs.ref);
|
|
227
|
+
if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
|
|
228
|
+
try {
|
|
229
|
+
revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (isRunControlError(err)) throw err;
|
|
232
|
+
revisions.set(id, { error: errorMessage(err) });
|
|
233
|
+
}
|
|
142
234
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
235
|
+
}
|
|
236
|
+
const results = compare(types, resources, found, revisions);
|
|
146
237
|
return { startedAt, finishedAt: await ctx.now(), problems, resources: results } satisfies DriftReport;
|
|
147
238
|
},
|
|
148
239
|
});
|
|
149
240
|
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A host's source: the clone URL its data file gives, or, when it names a declared repository
|
|
244
|
+
* (`target`), the first of the reference's `by` fields that holds one in that repository's state,
|
|
245
|
+
* read in this run.
|
|
246
|
+
*/
|
|
247
|
+
export function sourceUrl(
|
|
248
|
+
desired: State,
|
|
249
|
+
target: string | undefined,
|
|
250
|
+
{ host, fields }: Resource,
|
|
251
|
+
found: ReadonlyMap<string, Found>,
|
|
252
|
+
): string | undefined {
|
|
253
|
+
const path = host!.inputs.source;
|
|
254
|
+
const named = target === undefined ? undefined : found.get(target);
|
|
255
|
+
const by = fields.references?.[path]?.by ?? [];
|
|
256
|
+
const values =
|
|
257
|
+
target === undefined ? [valueAt(desired, path)] : named && "state" in named ? by.map((f) => named.state[f]) : [];
|
|
258
|
+
return values.find((v): v is string => typeof v === "string" && CLONE_URL.test(v));
|
|
259
|
+
}
|
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
|
/**
|